The globe under the Sun of now or of a post, with borders and country names
The Sun lights the globe where it really is (wwwroot/js/sun.js, the Astronomical Almanac's low-precision solar coordinates): the day map shaded by the Sun's height and the Earth–Sun distance, warm city lights on the night side, and a twilight blend about 13° wide between them. It shows now, refreshed every 30 s, or the moment of the hovered, focused or pinned post: a click on a card pins it, and the subsolar point travels there the shorter way round. The panel's top corner shows that moment, the phase of the day and solar time at the post's place, and the season in its hemisphere (or in both, for a post without a place). Its tooltip shows the subsolar point, declination, axial tilt, distance, irradiance, day length and the next equinox or solstice. Land borders are thin paths. Country names are in the reader's language and appear as the camera comes closer: largest countries first, away from the globe's edge, never overlapping. They come from world-atlas 2.0.2 (Natural Earth 1:50m), prepared by tools/globe/prepare-countries.py, and are vendored with their hashes in SOURCES.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
69d0a641fe
commit
0f68e74e38
13 files changed
+1066
-50
No files matched your search
@@ -198,20 +198,31 @@ supplies:
|
||||
`GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while
|
||||
the page is visible (PrivaPub has no streaming) and wait behind a "new" button.
|
||||
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
||||
`wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never
|
||||
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
|
||||
post, follows the theme (day/night texture), honours `prefers-reduced-motion`, and draws only while something moves
|
||||
(idle, it costs no GPU time; `wake()` before anything that animates). Each pixel computes its own map coordinates
|
||||
from its point on the sphere (`sharpen`), so the poles don't pinch; the map wraps horizontally and gets 8×
|
||||
anisotropic filtering (`keepSharp`). The day texture is NASA's, its seam repaired by
|
||||
`tools/globe/prepare-day-texture.py` (dev-only, needs Pillow). 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`.
|
||||
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
|
||||
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
|
||||
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
|
||||
time; `wake()` before anything that animates).
|
||||
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
|
||||
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
|
||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last request; the globe's camera,
|
||||
points and frame rate.
|
||||
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||
request; the globe's camera, points, 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`).**
|
||||
- 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
|
||||
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
|
||||
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. *@
|
||||
<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-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
|
||||
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
|
||||
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
|
||||
data-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)
|
||||
{
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
@inherits LocalizableComponentBase
|
||||
@using System.Globalization
|
||||
|
||||
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
||||
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
|
||||
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
|
||||
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||||
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
|
||||
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
|
||||
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
|
||||
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@
|
||||
<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">
|
||||
<span class="badge badge-ghost badge-xs gap-1">
|
||||
<i class="ion-md-planet"></i>
|
||||
@@ -37,6 +64,7 @@
|
||||
|
||||
ElementReference element;
|
||||
IJSObjectReference module;
|
||||
DotNetObjectReference<GlobePanel> reference;
|
||||
PeriodicTimer statsTimer;
|
||||
readonly CancellationTokenSource cancellation = new();
|
||||
string sentFingerprint;
|
||||
@@ -52,7 +80,9 @@
|
||||
if (firstRender)
|
||||
{
|
||||
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));
|
||||
_ = ReadStats(cancellation.Token);
|
||||
}
|
||||
@@ -100,6 +130,26 @@
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
|
||||
[JSInvokable]
|
||||
public async Task SunChanged()
|
||||
{
|
||||
if (module is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
async Task ReadStats(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -132,17 +182,82 @@
|
||||
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
|
||||
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
|
||||
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
|
||||
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
|
||||
yield return NerdRow.Of("WebGL", stats.Renderer);
|
||||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
await cancellation.CancelAsync();
|
||||
cancellation.Dispose();
|
||||
statsTimer?.Dispose();
|
||||
reference?.Dispose();
|
||||
try
|
||||
{
|
||||
if (module is not null)
|
||||
@@ -186,6 +301,37 @@
|
||||
public string Hovered { get; set; }
|
||||
public int Arcs { get; set; }
|
||||
public int Rings { get; set; }
|
||||
public int Countries { get; set; }
|
||||
public int CountryNames { get; set; }
|
||||
public int Borders { get; set; }
|
||||
public SunStats Sun { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
|
||||
sealed class SunStats
|
||||
{
|
||||
public string At { get; set; }
|
||||
public bool Now { get; set; }
|
||||
public double Latitude { get; set; }
|
||||
public double Longitude { get; set; }
|
||||
public double Distance { get; set; }
|
||||
public double Irradiance { get; set; }
|
||||
public double EclipticLongitude { get; set; }
|
||||
public double Obliquity { get; set; }
|
||||
public string Place { get; set; }
|
||||
public double? PlaceLatitude { get; set; }
|
||||
public double? PlaceLongitude { get; set; }
|
||||
public double? Elevation { get; set; }
|
||||
public string Phase { get; set; }
|
||||
public bool? Morning { get; set; }
|
||||
public string SolarTime { get; set; }
|
||||
public double? DayLength { get; set; }
|
||||
public string Season { get; set; }
|
||||
public string Hemisphere { get; set; }
|
||||
public string SeasonNorth { get; set; }
|
||||
public string SeasonSouth { get; set; }
|
||||
public string NextEvent { get; set; }
|
||||
public int? NextEventDays { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ public class FeedPost
|
||||
|
||||
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 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>
|
||||
<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" />
|
||||
@@ -686,4 +686,124 @@
|
||||
<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>
|
||||
+121
-1
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<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" />
|
||||
@@ -686,4 +686,124 @@
|
||||
<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>
|
||||
+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
|
||||
scheme in .invisible and shortens long links with .ellipsis), and the globe's point labels, which replace the
|
||||
look of globe.gl's own tooltip box. */
|
||||
scheme in .invisible and shortens long links with .ellipsis), the globe's point labels, which replace the look of
|
||||
globe.gl's own tooltip box, its CDN groups and country names, and the card it is pinned to. */
|
||||
@layer components {
|
||||
.post-content p {
|
||||
margin-block: 0 0.5em;
|
||||
@@ -88,4 +88,24 @@
|
||||
box-shadow: 0 1px 6px rgb(0 0 0 / 0.25);
|
||||
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)")
|
||||
+331
-24
@@ -4,13 +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
|
||||
// view. Posts of servers behind a CDN have no place: they are grouped per CDN in labels on a ring above the Arctic,
|
||||
// which says "CDN" rather than any place; hovering one draws its arc to that group and a ring where the server was
|
||||
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion. It
|
||||
// renders only while something moves (a camera flight, 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.
|
||||
// before the CDN, when PrivaPub saw it there.
|
||||
// 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
|
||||
// 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 textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
|
||||
const countriesUrl = vendor + "countries.json"
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
|
||||
|
||||
let loading = null
|
||||
@@ -30,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
|
||||
// scroll container.
|
||||
export function create(element, scrollContainerId) {
|
||||
// scroll container. reference is the .NET panel (SunChanged); options.language names the countries.
|
||||
export function create(element, scrollContainerId, reference, options) {
|
||||
dispose()
|
||||
const now = sunAt(new Date())
|
||||
state = {
|
||||
element,
|
||||
reference,
|
||||
names: regionNames(options?.language),
|
||||
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,
|
||||
points: new Map(),
|
||||
self: null,
|
||||
@@ -100,14 +124,50 @@ export function stats() {
|
||||
cdnGroups: cdnGroups().length,
|
||||
fps: state.fps,
|
||||
renderer: state.renderer,
|
||||
texture: state.texture,
|
||||
texture: state.sunUniforms.nightReady.value ? "day+night" : "day",
|
||||
anisotropy: state.anisotropy,
|
||||
autoRotate: !!state.globe.controls().autoRotate,
|
||||
paused: state.paused,
|
||||
idle: state.idle,
|
||||
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +232,28 @@ async function build(width, height) {
|
||||
.arcDashGap(0.25)
|
||||
.arcDashInitialGap(1)
|
||||
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600)
|
||||
.htmlLat(g => g.lat)
|
||||
.htmlLng(g => g.lng)
|
||||
.htmlAltitude(0.12)
|
||||
.htmlElement(cdnLabel)
|
||||
.htmlLat(d => d.lat)
|
||||
.htmlLng(d => d.lng)
|
||||
.htmlAltitude(d => d.kind === "country" ? 0.004 : 0.12)
|
||||
.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()
|
||||
controls.autoRotateSpeed = 0.35
|
||||
controls.enableDamping = true
|
||||
@@ -190,6 +267,10 @@ async function build(width, height) {
|
||||
})
|
||||
listen(state.element, "pointermove", () => wake(1000))
|
||||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
||||
state.timers.push(setInterval(() => {
|
||||
if (!state.focus)
|
||||
aimSun(sunAt(new Date()))
|
||||
}, 30000))
|
||||
applyTheme()
|
||||
readRenderer()
|
||||
countFrames()
|
||||
@@ -212,15 +293,32 @@ async function build(width, height) {
|
||||
// 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>\nvarying vec3 vSpherePoint;")
|
||||
.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);
|
||||
@@ -237,24 +335,212 @@ function sharpen(globe) {
|
||||
}
|
||||
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-per-pixel-sphere"
|
||||
material.customProgramCacheKey = () => "privapub-sunlit-sphere"
|
||||
material.needsUpdate = true
|
||||
state.anisotropy = Math.min(8, globe.renderer().capabilities.getMaxAnisotropy())
|
||||
}
|
||||
|
||||
// Every texture the globe loads (a theme change loads the other one) wraps around horizontally, so filtering at the
|
||||
// 180° meridian blends the map's two edges instead of clamping each, and gets anisotropic filtering.
|
||||
// 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 && (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping)) {
|
||||
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
|
||||
@@ -292,6 +578,29 @@ function watchCards() {
|
||||
hover(card.dataset.postId)
|
||||
})
|
||||
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 => {
|
||||
const from = event.target.closest?.("[data-post-id]")
|
||||
const to = event.relatedTarget?.closest?.("[data-post-id]")
|
||||
@@ -366,12 +675,6 @@ function settle() {
|
||||
function applyTheme() {
|
||||
if (!state?.globe)
|
||||
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.globe.atmosphereColor(colour("server", 1))
|
||||
drawPoints()
|
||||
@@ -452,7 +755,7 @@ function drawPoints() {
|
||||
return
|
||||
state.groups = cdnGroups()
|
||||
state.globe.pointsData(aggregate())
|
||||
state.globe.htmlElementsData(state.groups)
|
||||
state.globe.htmlElementsData([...state.groups.map(g => ({ kind: "cdn", ...g })), ...state.countries])
|
||||
drawRings()
|
||||
}
|
||||
|
||||
@@ -490,11 +793,13 @@ function drawRings() {
|
||||
}
|
||||
|
||||
function hover(key) {
|
||||
key ??= state?.pinned
|
||||
if (!state?.globe || state.hovered === key)
|
||||
return
|
||||
state.hovered = key
|
||||
wake(1300)
|
||||
const post = key && state.points.get(key)
|
||||
focusOn(key)
|
||||
drawRings()
|
||||
if (!post) {
|
||||
state.globe.arcsData([])
|
||||
@@ -598,6 +903,8 @@ function countFrames() {
|
||||
if (state.running)
|
||||
state.frames++
|
||||
keepSharp()
|
||||
stepSun()
|
||||
placeLabels()
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
@@ -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
+8
-2
@@ -1,8 +1,9 @@
|
||||
# The globe's vendored files
|
||||
# The globe's vendored files
|
||||
|
||||
Copied once on 2026-10-04 from the npm registry (registry.npmjs.org) and, for the day texture, from NASA
|
||||
(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`), and replace the hashes.
|
||||
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
|
||||
@@ -14,10 +15,15 @@ colour.
|
||||
| `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` | 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` |
|
||||
| `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
|
||||
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`.
|
||||
|
||||
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
|
||||
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
|
||||
Reference in new issue
Block a user