The globe's nerd stats are instruments in the scene

Owner decision 2026-10-06: the globe tells its numbers where they belong instead of in a table. instruments.js adds
one three.js group to globe.gl's scene and a DOM overlay of chips:
- the Sun: a ray and a bead over the subsolar point (with the irradiance), the day/night line, the golden hour and
  the three twilights, the declination, the noon and midnight meridian, and the analemma at this hour, its stretch to
  the next equinox or solstice brighter;
- a focused post's place: its horizon, its Sun's line and height, and its day lit on its latitude, with one chip for
  the place, the phase, the solar time, the elevation and the day's length;
- the tropics and polar circles (the axial tilt), the CDN ring, the view's centre and spread, and rings around the
  posts in view;
- a small orrery in the top left corner, re-anchored to the camera before each render, with the Earth–Sun distance,
  the seasons, the next equinox or solstice and the next perihelion or aphelion stacked under it;
- the frame rate as a ring around the globe; the rest (points, textures, countries, renderer) as a legend.

The chips are placed after each frame globe.gl draws, clear of each other, the moment's caption, the orrery and the
legend, and slide back inside the canvas at its edge; country names keep clear of them. Geometry changes only inside
a wake globe.js already made, so an idle globe still draws nothing. three.js's classes come from globe.gl's own
objects, looked for before each render until its globe is in the scene. A phone's banner draws the day/night line
and the subsolar point; full screen draws everything, and a globe built in the banner now moves its camera back when
opened full screen.

GlobePanel loses both tooltips and the one-second poll that read every post into a table; the Sun's rows stay as
screen-reader text, and a failed start says so. Seven keys only the tooltips used are gone.

GlobeTests (desktop with a located post written from Milano and pinned, in both themes and turned to the subsolar
point; a phone's banner, full screen and back; an idle globe drawing no frame) pass. Under SwiftShader the instruments
cost 1–3 ms of a ~50 ms frame and under 0.1 ms of script; FeedPerfTests' heaps are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 02:15:06 +02:00
1 parent e88f4afc45
commit e94e32d198
9 files changed
+1513 -178

No files matched your search

+31 -4
View File
@@ -87,6 +87,14 @@ tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentNam
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the - `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`). full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
- `DesktopUnchangedTests` holds desktop's control sizes. - `DesktopUnchangedTests` holds desktop's control sizes.
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
turned to the subsolar point.
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
to the banner.
- An idle globe draws no frame.
Photos and stats go to `tests/e2e/out/globe/`.
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests` - **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time, scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
DOM nodes, JS and .NET heaps and long tasks to `perf.json`. DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
@@ -397,8 +405,28 @@ supplies:
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the 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 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, 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 `data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's
in the panel's top corner. The theme changes only the atmosphere and the points. top and keeps the Sun's numbers as screen-reader text (`aria-describedby`). The theme changes only the atmosphere and
the points.
- **Instruments** (`wwwroot/js/instruments.js`; owner decision 2026-10-06: the nerd stats drawn where they belong, not
a table). The globe has no tooltip. What it draws:
- the Sun: its ray and bead over the subsolar point, the day/night line and the twilights, the declination, the
noon and midnight meridian, and its analemma at this hour to the next equinox or solstice;
- a focused post's place: its horizon, its Sun's height and its day lit on its latitude;
- the tropics, the polar circles and the CDN ring;
- the view's centre and spread, and rings around the posts in view;
- a small orrery in the top left corner, re-anchored to the camera before each render.
The numbers are chips in a DOM overlay (`.globe-instruments`, `aria-hidden`), placed after each frame globe.gl renders
(`scene.onAfterRender`), clear of each other, the moment's caption, the orrery and the legend. The frame rate is a
ring around the globe; what is left is a legend in the bottom left. Geometry changes only inside a wake globe.js
already made, so an idle globe still draws nothing.
- three.js's classes come from objects globe.gl made (`classesOf`), never `window.THREE`, since that would mix two
copies. three.js sets its `is*` flags on instances, so the lookup checks instances. globe.gl adds its globe on its
first update, after `new Globe()`, so the parts are built before the first render that finds it. Without the
classes, only the ring and the legend remain.
- A canvas under 320px tall (a phone's banner) gets the day/night line and the subsolar point only; full screen
gets everything.
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they - **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 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 (`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
@@ -409,8 +437,7 @@ supplies:
- **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 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, request. The globe's are its instruments (see **The globe**), with no tooltip and no poll.
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
+60 -120
View File
@@ -3,36 +3,41 @@
@* 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, lit by the Sun of now or of the moment the hovered, focused or pinned post 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 was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. Below lg the module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
globe is a banner the page shows above the feed: a tap opens it full screen (Expanded, which the page lays out), and polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
its close button or Escape brings the feed back. *@ above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
brings the feed back. *@
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State"> <div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div> <div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
<dl id="@($"{uid}-sun")" class="sr-only">
@if (sun is not null)
{
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt>@row.Label</dt>
<dd>@row.Value</dd>
}
}
</dl>
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
@if (stats?.Sun is { } sun) @* the moment the Sun is shown at: now, or the focused post's *@
@if (sun is not null)
{ {
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] pointer-coarse:text-xs leading-tight pointer-events-auto"> <p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start"> data-globe-moment>
<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"> <i class="@SunIcon(sun)" aria-hidden="true"></i>
<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>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
</span> </p>
@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 else if (error is { Length: > 0 })
{ {
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span> <p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
}
</div>
</NerdTip>
</div>
} }
@if (Expanded) @if (Expanded)
@@ -50,25 +55,6 @@
</button> </button>
} }
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] pointer-coarse:text-xs font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1">
<i class="ion-md-planet"></i>
@if (stats is { Error: null } live)
{
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
}
else if (stats?.Error is { Length: > 0 })
{
<span>@Localizer["The globe could not start."]</span>
}
else
{
<span>@Localizer["loading"]…</span>
}
</span>
</NerdTip>
</div>
</div> </div>
@code { @code {
@@ -90,7 +76,20 @@
bool focusClose; bool focusClose;
bool focusExpand; bool focusExpand;
string State => stats is null ? "loading" : stats.Error is { Length: > 0 } ? "error" : "live"; string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
static readonly string[] InstrumentKeys =
[
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
];
async Task Open() async Task Open()
{ {
@@ -109,12 +108,13 @@
ElementReference element; ElementReference element;
IJSObjectReference module; IJSObjectReference module;
DotNetObjectReference<GlobePanel> reference; DotNetObjectReference<GlobePanel> reference;
PeriodicTimer statsTimer;
readonly CancellationTokenSource cancellation = new(); readonly CancellationTokenSource cancellation = new();
int? sentFingerprint; int? sentFingerprint;
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders // the posts are looked at again only when the feed hands them over, never on the panel's own renders
bool postsHandedOver = true; bool postsHandedOver = true;
GlobeStats stats; SunStats sun;
string error;
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
protected override void OnInitialized() => IsLoading = false; protected override void OnInitialized() => IsLoading = false;
@@ -150,10 +150,11 @@
{ {
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js"); module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
reference = DotNetObjectReference.Create(this); reference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName }); {
statsTimer = new(TimeSpan.FromSeconds(1)); Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
_ = ReadStats(cancellation.Token); Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
});
} }
if (postsHandedOver) if (postsHandedOver)
{ {
@@ -213,7 +214,8 @@
: null; : null;
} }
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary> /// <summary>The globe started, or its Sun moved to another moment (now, or a post's): the caption and the screen
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
[JSInvokable] [JSInvokable]
public async Task SunChanged() public async Task SunChanged()
{ {
@@ -222,7 +224,7 @@
try try
{ {
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token); sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
StateHasChanged(); StateHasChanged();
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -233,42 +235,12 @@
} }
} }
async Task ReadStats(CancellationToken cancellationToken) /// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
[JSInvokable]
public void GlobeFailed(string message)
{ {
try error = message;
{ StateHasChanged();
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
IEnumerable<NerdRow> StatsRows()
{
if (stats is null)
yield break;
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
yield return NerdRow.Of("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error);
} }
IEnumerable<NerdRow> SunRows(SunStats sun) IEnumerable<NerdRow> SunRows(SunStats sun)
@@ -340,7 +312,6 @@
overlay?.Dispose(); overlay?.Dispose();
await cancellation.CancelAsync(); await cancellation.CancelAsync();
cancellation.Dispose(); cancellation.Dispose();
statsTimer?.Dispose();
reference?.Dispose(); reference?.Dispose();
try try
{ {
@@ -361,37 +332,6 @@
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host); sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
sealed class GlobeStats
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Altitude { get; set; }
public int Visible { get; set; }
public int VisibleLocated { get; set; }
public double? CentroidLatitude { get; set; }
public double? CentroidLongitude { get; set; }
public double SpreadKm { get; set; }
public int Points { get; set; }
public int Posts { get; set; }
public int Servers { get; set; }
public int CdnGroups { get; set; }
public int Fps { get; set; }
public string Renderer { get; set; }
public string Texture { get; set; }
public int Anisotropy { get; set; }
public bool AutoRotate { get; set; }
public bool Paused { get; set; }
public bool Idle { get; set; }
public string Hovered { get; set; }
public int Arcs { get; set; }
public int Rings { get; set; }
public int Countries { get; set; }
public int CountryNames { get; set; }
public int Borders { get; set; }
public SunStats Sun { get; set; }
public string Error { get; set; }
}
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary> /// <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 sealed class SunStats
{ {
+48 -21
View File
@@ -569,9 +569,6 @@
<data name="spread" xml:space="preserve"> <data name="spread" xml:space="preserve">
<value>dispersione</value> <value>dispersione</value>
</data> </data>
<data name="texture" xml:space="preserve">
<value>texture</value>
</data>
<data name="the author&#x27;s server" xml:space="preserve"> <data name="the author&#x27;s server" xml:space="preserve">
<value>il server dell'autore</value> <value>il server dell'autore</value>
</data> </data>
@@ -629,15 +626,6 @@
<data name="{0}: {1}" xml:space="preserve"> <data name="{0}: {1}" xml:space="preserve">
<value>{0}: {1}</value> <value>{0}: {1}</value>
</data> </data>
<data name="hovered" xml:space="preserve">
<value>sotto il puntatore</value>
</data>
<data name="arcs" xml:space="preserve">
<value>archi</value>
</data>
<data name="rings" xml:space="preserve">
<value>anelli</value>
</data>
<data name="placed" xml:space="preserve"> <data name="placed" xml:space="preserve">
<value>collocati</value> <value>collocati</value>
</data> </data>
@@ -683,12 +671,6 @@
<data name="idle" xml:space="preserve"> <data name="idle" xml:space="preserve">
<value>fermo</value> <value>fermo</value>
</data> </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"> <data name="Now" xml:space="preserve">
<value>Ora</value> <value>Ora</value>
</data> </data>
@@ -710,9 +692,6 @@
<data name="countries" xml:space="preserve"> <data name="countries" xml:space="preserve">
<value>paesi</value> <value>paesi</value>
</data> </data>
<data name="named at this zoom" xml:space="preserve">
<value>con nome a questo zoom</value>
</data>
<data name="borders" xml:space="preserve"> <data name="borders" xml:space="preserve">
<value>confini</value> <value>confini</value>
</data> </data>
@@ -1871,4 +1850,52 @@
<data name="Picture {0} of {1}" xml:space="preserve"> <data name="Picture {0} of {1}" xml:space="preserve">
<value>Immagine {0} di {1}</value> <value>Immagine {0} di {1}</value>
</data> </data>
<data name="day/night line" xml:space="preserve">
<value>linea giorno/notte</value>
</data>
<data name="solar noon" xml:space="preserve">
<value>mezzogiorno solare</value>
</data>
<data name="solar midnight" xml:space="preserve">
<value>mezzanotte solare</value>
</data>
<data name="Tropic of Cancer" xml:space="preserve">
<value>Tropico del Cancro</value>
</data>
<data name="Tropic of Capricorn" xml:space="preserve">
<value>Tropico del Capricorno</value>
</data>
<data name="Arctic Circle" xml:space="preserve">
<value>Circolo polare artico</value>
</data>
<data name="Antarctic Circle" xml:space="preserve">
<value>Circolo polare antartico</value>
</data>
<data name="this hour, all year" xml:space="preserve">
<value>a quest'ora, tutto l'anno</value>
</data>
<data name="the Sun never sets" xml:space="preserve">
<value>il Sole non tramonta</value>
</data>
<data name="the Sun never rises" xml:space="preserve">
<value>il Sole non sorge</value>
</data>
<data name="perihelion" xml:space="preserve">
<value>perielio</value>
</data>
<data name="aphelion" xml:space="preserve">
<value>afelio</value>
</data>
<data name="not to scale" xml:space="preserve">
<value>non in scala</value>
</data>
<data name="day map" xml:space="preserve">
<value>mappa del giorno</value>
</data>
<data name="day and night maps" xml:space="preserve">
<value>mappe del giorno e della notte</value>
</data>
<data name="{0}× anisotropic" xml:space="preserve">
<value>anisotropia {0}×</value>
</data>
</root> </root>
+48 -21
View File
@@ -569,9 +569,6 @@
<data name="spread" xml:space="preserve"> <data name="spread" xml:space="preserve">
<value>spread</value> <value>spread</value>
</data> </data>
<data name="texture" xml:space="preserve">
<value>texture</value>
</data>
<data name="the author&#x27;s server" xml:space="preserve"> <data name="the author&#x27;s server" xml:space="preserve">
<value>the author's server</value> <value>the author's server</value>
</data> </data>
@@ -629,15 +626,6 @@
<data name="{0}: {1}" xml:space="preserve"> <data name="{0}: {1}" xml:space="preserve">
<value>{0}: {1}</value> <value>{0}: {1}</value>
</data> </data>
<data name="hovered" xml:space="preserve">
<value>hovered</value>
</data>
<data name="arcs" xml:space="preserve">
<value>arcs</value>
</data>
<data name="rings" xml:space="preserve">
<value>rings</value>
</data>
<data name="placed" xml:space="preserve"> <data name="placed" xml:space="preserve">
<value>placed</value> <value>placed</value>
</data> </data>
@@ -683,12 +671,6 @@
<data name="idle" xml:space="preserve"> <data name="idle" xml:space="preserve">
<value>idle</value> <value>idle</value>
</data> </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"> <data name="Now" xml:space="preserve">
<value>Now</value> <value>Now</value>
</data> </data>
@@ -710,9 +692,6 @@
<data name="countries" xml:space="preserve"> <data name="countries" xml:space="preserve">
<value>countries</value> <value>countries</value>
</data> </data>
<data name="named at this zoom" xml:space="preserve">
<value>named at this zoom</value>
</data>
<data name="borders" xml:space="preserve"> <data name="borders" xml:space="preserve">
<value>borders</value> <value>borders</value>
</data> </data>
@@ -1871,4 +1850,52 @@
<data name="Picture {0} of {1}" xml:space="preserve"> <data name="Picture {0} of {1}" xml:space="preserve">
<value>Picture {0} of {1}</value> <value>Picture {0} of {1}</value>
</data> </data>
<data name="day/night line" xml:space="preserve">
<value>day/night line</value>
</data>
<data name="solar noon" xml:space="preserve">
<value>solar noon</value>
</data>
<data name="solar midnight" xml:space="preserve">
<value>solar midnight</value>
</data>
<data name="Tropic of Cancer" xml:space="preserve">
<value>Tropic of Cancer</value>
</data>
<data name="Tropic of Capricorn" xml:space="preserve">
<value>Tropic of Capricorn</value>
</data>
<data name="Arctic Circle" xml:space="preserve">
<value>Arctic Circle</value>
</data>
<data name="Antarctic Circle" xml:space="preserve">
<value>Antarctic Circle</value>
</data>
<data name="this hour, all year" xml:space="preserve">
<value>this hour, all year</value>
</data>
<data name="the Sun never sets" xml:space="preserve">
<value>the Sun never sets</value>
</data>
<data name="the Sun never rises" xml:space="preserve">
<value>the Sun never rises</value>
</data>
<data name="perihelion" xml:space="preserve">
<value>perihelion</value>
</data>
<data name="aphelion" xml:space="preserve">
<value>aphelion</value>
</data>
<data name="not to scale" xml:space="preserve">
<value>not to scale</value>
</data>
<data name="day map" xml:space="preserve">
<value>day map</value>
</data>
<data name="day and night maps" xml:space="preserve">
<value>day and night maps</value>
</data>
<data name="{0}× anisotropic" xml:space="preserve">
<value>{0}× anisotropic</value>
</data>
</root> </root>
+131
View File
@@ -130,4 +130,135 @@
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 2px; outline-offset: 2px;
} }
/* The globe's instruments (wwwroot/js/instruments.js): an overlay of chips beside what is drawn in the scene, the
frame-rate ring around the globe and a two-line legend. It never takes the pointer. Each chip's colour is its
instrument's: the Sun's warning, the twilights' info, the place's accent, the view's primary. */
.globe-instruments {
position: absolute;
inset: 0;
z-index: 1000;
overflow: hidden;
pointer-events: none;
contain: layout paint;
}
.globe-tag {
--tag: var(--color-base-content);
position: absolute;
left: 0;
top: 0;
font: 10px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
padding: 0.1rem 0.4rem;
border-radius: 999px;
color: var(--color-base-content);
background: color-mix(in oklab, var(--color-base-100) 78%, transparent);
border: 1px solid color-mix(in oklab, var(--tag) 70%, transparent);
box-shadow: 0 1px 4px rgb(0 0 0 / 0.2);
will-change: transform;
}
.globe-tag-sun,
.globe-tag-sun-faint {
--tag: var(--color-warning);
}
.globe-tag-twilight {
--tag: var(--color-info);
}
/* a place's chip: its place, phase and solar time over its Sun's height and its day */
.globe-tag-place {
--tag: var(--color-accent);
white-space: pre;
text-align: center;
border-radius: 0.6rem;
}
.globe-tag-server {
--tag: var(--color-primary);
}
.globe-tag-white {
--tag: rgb(255 255 255);
}
.globe-tag-orrery {
--tag: var(--color-base-content);
}
.globe-tag-sun-faint,
.globe-tag-orrery-faint {
opacity: 0.7;
border-style: dashed;
}
.globe-tag-ring {
--tag: var(--color-base-content);
opacity: 0.85;
}
/* a place's chip says the phase of its day */
.globe-tag-place[data-phase="day"] {
--tag: var(--color-warning);
}
.globe-tag-place[data-phase="night"],
.globe-tag-place[data-phase="astronomical"] {
--tag: var(--color-neutral);
}
.globe-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.globe-ring-track {
fill: none;
stroke: color-mix(in oklab, var(--color-base-content) 18%, transparent);
stroke-width: 1.5;
}
.globe-ring-value {
fill: none;
stroke: var(--color-success);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dasharray 0.6s ease-out;
}
.globe-ring[data-pace="fair"] .globe-ring-value {
stroke: var(--color-warning);
}
.globe-ring[data-pace="poor"] .globe-ring-value {
stroke: var(--color-error);
}
.globe-ring[data-pace="idle"] .globe-ring-track,
.globe-ring[data-pace="paused"] .globe-ring-track {
stroke-dasharray: 2 6;
}
@media (prefers-reduced-motion: reduce) {
.globe-ring-value {
transition: none;
}
}
.globe-legend {
position: absolute;
left: 0.5rem;
bottom: 0.5rem;
max-width: calc(100% - 1rem);
font: 9px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-line;
color: var(--color-base-content);
opacity: 0.65;
text-shadow: 0 0 3px var(--color-base-100);
}
} }
+159
View File
@@ -0,0 +1,159 @@
using System.Text.Json;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Render
{
// The globe's instruments (wwwroot/js/instruments.js): drawn in the desktop column and in a phone's full screen with
// the Sun where it should be, only the day/night line in a phone's banner, and an idle globe that draws nothing.
// Screenshots for the eye go to tests/e2e/out/globe/.
public sealed class GlobeTests(Browser browser)
{
static readonly string OutDir = Path.Combine(Directory.GetParent(AppContext.BaseDirectory)!.Parent!.Parent!.Parent!.FullName, "out", "globe");
static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
static async Task<JsonElement> Stats(IPage page) =>
await page.EvaluateAsync<JsonElement>("async () => (await import('./js/globe.js')).stats()");
static async Task Live(IPage page)
{
await page.Locator("[data-globe-state=live]").First.WaitForAsync(new() { Timeout = 30000 });
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).TryGetProperty("instruments", out var i) && i.ValueKind == JsonValueKind.Object, 15),
"the instruments never reported");
}
static async Task Photo(IPage page, ILocator what, string name)
{
Directory.CreateDirectory(OutDir);
await page.WaitForTimeoutAsync(1500);
await what.ScreenshotAsync(new() { Path = Path.Combine(OutDir, name + ".png") });
}
[Fact]
public async Task The_desktop_column_draws_the_instruments_with_the_sun_in_place()
{
await using var session = await SignIn.As(browser, Persona());
var page = session.Page;
await Results.Cell("client.render.globe", "privapub", "desktop", async () =>
{
await Live(page);
// a located post, written here from Milano and read back in Nearby, pinned: the globe shows its moment, its
// place and its day
await page.Context.GrantPermissionsAsync(["geolocation"]);
await page.Context.SetGeolocationAsync(new() { Latitude = 45.4642f, Longitude = 9.19f });
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
var text = $"the globe's instruments {Guid.NewGuid():N}"[..32];
await page.Locator("textarea[data-compose]").First.FillAsync(text);
await page.Locator("button[title='Located post']").ClickAsync();
var submit = page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
await Assertions.Expect(submit).ToBeEnabledAsync();
await submit.ClickAsync();
await Assertions.Expect(page.Locator("textarea[data-compose]")).ToHaveCountAsync(0);
await page.Locator("[role=tab][title='Nearby']").ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Use my position" }).ClickAsync();
var card = page.Locator("#feed-scroll article[data-lat][data-post-at]", new() { HasText = text }).First;
await card.WaitForAsync(new() { Timeout = 20000 });
// the view flies to it first (with the pointer off the feed: a hovered post holds the view), then the card is pinned
await page.Mouse.MoveAsync(2, 2);
Assert.True(await Pasture.Eventually(async () =>
{
var view = await Stats(page);
return view.GetProperty("centroidLatitude").ValueKind == JsonValueKind.Number
&& Math.Abs(view.GetProperty("latitude").GetDouble() - view.GetProperty("centroidLatitude").GetDouble()) < 1;
}, 10), "the globe never looked at the located post");
var box = (await card.BoundingBoxAsync())!;
await card.ClickAsync(new() { Position = new() { X = box.Width - 6, Y = box.Height * 0.35f } });
await Assertions.Expect(page.Locator("[data-globe-pinned]")).ToHaveCountAsync(1);
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("chips").GetInt32() >= 6, 10),
"the pinned post's instruments have few chips");
var stats = await Stats(page);
var instruments = stats.GetProperty("instruments");
Assert.True(instruments.GetProperty("built").GetBoolean(), $"the instruments weren't built: {instruments}");
Assert.Equal("full", instruments.GetProperty("detail").GetString());
Assert.True(instruments.GetProperty("subsolarError").GetDouble() < 0.01, $"the Sun is drawn {instruments.GetProperty("subsolarError")} units off");
var globe = page.Locator("aside").First;
await Photo(page, globe, "desktop-light");
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop.json"), (await Stats(page)).ToString());
await page.EvaluateAsync("() => document.documentElement.setAttribute('data-theme', 'neo-dark')");
await Photo(page, globe, "desktop-dark");
// turned to the subsolar point, by dragging (about 3 px a degree; dragging left looks east, up looks south), from a
// little west and north of it, so that its ray stands out: the Sun's own instruments
var canvas = (await globe.Locator("canvas").First.BoundingBoxAsync())!;
var (cx, cy) = (canvas.X + canvas.Width / 2, canvas.Y + canvas.Height / 2);
for (var step = 0; step < 8; step++)
{
var now = await Stats(page);
var dLng = (now.GetProperty("sun").GetProperty("longitude").GetDouble() - 18 - now.GetProperty("longitude").GetDouble() + 540) % 360 - 180;
var dLat = now.GetProperty("sun").GetProperty("latitude").GetDouble() + 12 - now.GetProperty("latitude").GetDouble();
if (Math.Abs(dLng) < 8 && Math.Abs(dLat) < 8)
break;
await page.Mouse.MoveAsync(cx, cy);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(cx - (float)Math.Clamp(dLng * 3, -200, 200), cy + (float)Math.Clamp(dLat * 3, -200, 200), new() { Steps = 10 });
await page.Mouse.UpAsync();
await page.WaitForTimeoutAsync(800);
}
await Photo(page, globe, "desktop-sun");
await File.WriteAllTextAsync(Path.Combine(OutDir, "desktop-sun.json"), (await Stats(page)).ToString());
// and taken back by its author, the one thing a located post's card offers
await card.Locator("[data-located-delete]").ClickAsync();
await page.Locator("[data-confirm-yes]").ClickAsync();
await Assertions.Expect(card).ToHaveCountAsync(0);
await session.AssertGuards();
}, page);
}
[Fact]
public async Task A_phone_banner_draws_little_and_its_full_screen_everything()
{
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
var page = session.Page;
await Results.Cell("client.render.globe", "privapub", "phone", async () =>
{
await page.Locator("button[title='Globe']").TapAsync();
await Live(page);
Assert.Equal("banner", (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString());
await page.Locator("[data-globe-expand]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "full", 10),
"the full screen globe has the banner's instruments");
// the camera moves back for a screen taller than wide, so the globe fits across it
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("altitude").GetDouble() > 3.5, 10),
"the full screen globe kept the banner's camera");
Assert.True((await Stats(page)).GetProperty("instruments").GetProperty("built").GetBoolean(), "the instruments weren't built");
await Photo(page, page.Locator("aside[role=dialog]"), "phone-full");
// and closed, the banner again
await page.Locator("[data-globe-close]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("instruments").GetProperty("detail").GetString() == "banner", 10),
"the banner kept the full screen's instruments");
await session.AssertGuards();
}, page);
}
// with reduced motion nothing turns or flies: once settled, no frame is drawn, the instruments' neither
[Fact]
public async Task An_idle_globe_draws_nothing()
{
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7);
var page = session.Page;
await Results.Cell("client.render.globe-idle", "privapub", "phone", async () =>
{
await page.Locator("button[title='Globe']").TapAsync();
await page.Locator("[data-globe-expand]").TapAsync();
await Live(page);
Assert.True(await Pasture.Eventually(async () => (await Stats(page)).GetProperty("idle").GetBoolean(), 20), "the globe never settled");
var before = await Stats(page);
await page.WaitForTimeoutAsync(3000);
var after = await Stats(page);
Assert.Equal(before.GetProperty("frame").GetInt32(), after.GetProperty("frame").GetInt32());
Assert.Equal(before.GetProperty("instruments").GetProperty("frames").GetInt32(), after.GetProperty("instruments").GetProperty("frames").GetInt32());
Assert.Equal(0, after.GetProperty("fps").GetInt32());
await session.AssertGuards();
}, page);
}
}
}
+58 -12
View File
@@ -16,6 +16,7 @@
// data-post-id, and .NET only hears that the Sun's moment changed (SunChanged), to show it. // 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" import { directionOf, seasonsOf, sunAt, sunAtPlace } from "./sun.js"
import { createInstruments } from "./instruments.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" }
@@ -49,6 +50,9 @@ export function create(element, scrollContainerId, reference, options) {
element, element,
reference, reference,
names: regionNames(options?.language), names: regionNames(options?.language),
language: options?.language,
labels: options?.labels ?? {},
instruments: null,
scroller: document.getElementById(scrollContainerId), scroller: document.getElementById(scrollContainerId),
sun: now, sun: now,
sunPoint: { lat: now.lat, lng: now.lng }, sunPoint: { lat: now.lat, lng: now.lng },
@@ -137,12 +141,18 @@ export function stats() {
countries: state.countries.length, countries: state.countries.length,
countryNames: state.countryNames, countryNames: state.countryNames,
borders: state.borders.length, borders: state.borders.length,
sun: sunStats() sun: sunStats(),
// for the end-to-end tests: what the instruments drew, and the frames rendered so far (still while idle)
instruments: state.instruments?.summary() ?? null,
frame: state.globe.renderer().info.render.frame
} }
} }
// The Sun's moment as the overlay shows it: now, or the focused post's moment and what the Sun does at its place. // The Sun's moment as the panel's caption and its text for screen readers show it: now, or the focused post's moment
function sunStats() { // and what the Sun does at its place; null until the globe exists.
export function sunStats() {
if (!state)
return null
const sun = state.sun const sun = state.sun
const focus = state.focus const focus = state.focus
const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(sun, focus.lat, focus.lng) : null const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(sun, focus.lat, focus.lng) : null
@@ -181,6 +191,7 @@ export function dispose() {
state.timers.forEach(timer => clearInterval(timer)) state.timers.forEach(timer => clearInterval(timer))
if (state.globe) { if (state.globe) {
state.globe.pauseAnimation() state.globe.pauseAnimation()
state.instruments?.dispose()
state.globe._destructor?.() state.globe._destructor?.()
state.element.replaceChildren() state.element.replaceChildren()
} }
@@ -198,6 +209,7 @@ function resize() {
return return
} }
state.globe.width(width).height(height) state.globe.width(width).height(height)
state.instruments?.setSize(width, height)
pause(document.visibilityState !== "visible") pause(document.visibilityState !== "visible")
wake() wake()
// a new shape (the phone's banner opened full screen, or closed again) wants the camera at another height // a new shape (the phone's banner opened full screen, or closed again) wants the camera at another height
@@ -263,6 +275,12 @@ async function build(width, height) {
.pathTransitionDuration(0) .pathTransitionDuration(0)
.globeImageUrl(textures.day) .globeImageUrl(textures.day)
sharpen(globe) sharpen(globe)
// the nerd stats, drawn in the scene (instruments.js)
state.instruments = createInstruments(globe, state.element, { labels: state.labels, language: state.language, colour })
state.instruments.setSize(width, height)
state.instruments.setFacts({ anisotropy: state.anisotropy })
state.instruments.setMoment(state.sun, state.focus)
state.instruments.setSun(state.sunPoint)
loadNight() loadNight()
loadCountries() loadCountries()
const controls = globe.controls() const controls = globe.controls()
@@ -277,7 +295,8 @@ async function build(width, height) {
wake(1500) wake(1500)
}) })
listen(state.element, "pointermove", () => wake(1000)) listen(state.element, "pointermove", () => wake(1000))
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0) state.shape = narrowness()
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * state.shape }, 0)
state.timers.push(setInterval(() => { state.timers.push(setInterval(() => {
if (!state.focus) if (!state.focus)
aimSun(sunAt(new Date())) aimSun(sunAt(new Date()))
@@ -288,10 +307,12 @@ async function build(width, height) {
drawPoints() drawPoints()
scheduleView() scheduleView()
pause(document.visibilityState !== "visible") pause(document.visibilityState !== "visible")
state.reference?.invokeMethodAsync("SunChanged").catch(() => { })
} catch (e) { } catch (e) {
state.error = e.message state.error = e.message
console.error(e) console.error(e)
window.logFromJs?.(e.message, "globe.build") window.logFromJs?.(e.message, "globe.build")
state.reference?.invokeMethodAsync("GlobeFailed", e.message).catch(() => { })
} finally { } finally {
if (state === current) if (state === current)
state.building = false state.building = false
@@ -384,6 +405,7 @@ function keepSharp() {
night.needsUpdate = true night.needsUpdate = true
state.sunUniforms.nightMap.value = night state.sunUniforms.nightMap.value = night
state.sunUniforms.nightReady.value = 1 state.sunUniforms.nightReady.value = 1
state.instruments?.setFacts({ night: true })
wake(500) wake(500)
} }
} }
@@ -405,6 +427,8 @@ function aimSun(sun) {
if (reducedMotion.matches) if (reducedMotion.matches)
state.sunPoint = { lat: sun.lat, lng: sun.lng } state.sunPoint = { lat: sun.lat, lng: sun.lng }
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
state.instruments?.setMoment(sun, state.focus)
state.instruments?.setSun(state.sunPoint)
// one frame for the new irradiance; stepSun keeps the globe drawing while the Sun travels // one frame for the new irradiance; stepSun keeps the globe drawing while the Sun travels
wake(100) wake(100)
state.reference?.invokeMethodAsync("SunChanged").catch(() => { }) state.reference?.invokeMethodAsync("SunChanged").catch(() => { })
@@ -419,12 +443,14 @@ function stepSun() {
if (point.lat !== state.sun.lat || point.lng !== state.sun.lng) { if (point.lat !== state.sun.lat || point.lng !== state.sun.lng) {
state.sunPoint = { lat: state.sun.lat, lng: state.sun.lng } state.sunPoint = { lat: state.sun.lat, lng: state.sun.lng }
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
state.instruments?.setSun(state.sunPoint)
} }
return return
} }
const k = Math.abs(dLat) < 0.5 && Math.abs(dLng) < 0.5 ? 1 : 0.12 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.sunPoint = { lat: point.lat + dLat * k, lng: point.lng + dLng * k }
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
state.instruments?.setSun(state.sunPoint)
wake(300) wake(300)
} }
@@ -472,6 +498,7 @@ async function loadCountries() {
}) })
state.countries = data.countries.map(c => ({ kind: "country", ...c })) state.countries = data.countries.map(c => ({ kind: "country", ...c }))
state.globe.pathsData(state.borders) state.globe.pathsData(state.borders)
state.instruments?.setFacts({ countries: state.countries.length, borders: state.borders.length })
drawPoints() drawPoints()
placeLabels(true) placeLabels(true)
wake(500) wake(500)
@@ -522,7 +549,8 @@ function placeLabels(force = false) {
const perDegree = pixelsPerDegree(camera.altitude) const perDegree = pixelsPerDegree(camera.altitude)
const horizon = 1 / (1 + camera.altitude) const horizon = 1 / (1 + camera.altitude)
const view = unit(camera.lat, camera.lng) const view = unit(camera.lat, camera.lng)
const taken = [] const chips = state.instruments?.boxes() ?? []
const taken = [...chips]
for (const country of state.countries) { for (const country of state.countries) {
const element = state.labelOf.get(country) const element = state.labelOf.get(country)
if (!element) if (!element)
@@ -546,7 +574,8 @@ function placeLabels(force = false) {
} }
element.style.visibility = shown ? "" : "hidden" element.style.visibility = shown ? "" : "hidden"
} }
state.countryNames = taken.length state.countryNames = taken.length - chips.length
state.instruments?.setFacts({ named: state.countryNames })
} }
function unit(lat, lng) { function unit(lat, lng) {
@@ -688,6 +717,7 @@ function applyTheme() {
return return
state.colours = null state.colours = null
state.globe.atmosphereColor(colour("server", 1)) state.globe.atmosphereColor(colour("server", 1))
state.instruments?.setColours()
drawPoints() drawPoints()
wake(2000) wake(2000)
} }
@@ -707,7 +737,12 @@ function colour(kind, alpha = 0.95) {
place: read("--color-accent"), place: read("--color-accent"),
country: read("--color-secondary"), country: read("--color-secondary"),
nearyou: read("--color-success"), nearyou: read("--color-success"),
self: read("--color-base-content") self: read("--color-base-content"),
// the instruments': the Sun, the twilights, the orrery's lines and the Earth's night side
sun: read("--color-warning"),
twilight: read("--color-info"),
content: read("--color-base-content"),
night: read("--color-neutral")
} }
probe.remove() probe.remove()
return colours return colours
@@ -765,7 +800,11 @@ function drawPoints() {
if (!state?.globe) if (!state?.globe)
return return
state.groups = cdnGroups() state.groups = cdnGroups()
state.globe.pointsData(aggregate()) const points = aggregate()
state.globe.pointsData(points)
state.instruments?.setPoints({
points: points.length, posts: state.points.size, servers: new Set([...state.points.values()].map(p => p.host)).size, cdnGroups: state.groups.length
})
state.globe.htmlElementsData([...state.groups.map(g => ({ kind: "cdn", ...g })), ...state.countries]) state.globe.htmlElementsData([...state.groups.map(g => ({ kind: "cdn", ...g })), ...state.countries])
drawRings() drawRings()
} }
@@ -814,12 +853,15 @@ function hover(key) {
drawRings() drawRings()
if (!post) { if (!post) {
state.globe.arcsData([]) state.globe.arcsData([])
state.instruments?.setArc(null)
fly(state.view, 900) fly(state.view, 900)
return return
} }
if (!placed(post)) { if (!placed(post)) {
const group = state.groups?.find(g => g.key === post.cdn) const group = state.groups?.find(g => g.key === post.cdn)
state.globe.arcsData(group && state.self ? [{ startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng }] : []) const toCdn = group && state.self ? { startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng, label: group.name } : null
state.globe.arcsData(toCdn ? [toCdn] : [])
state.instruments?.setArc(toCdn)
const target = Number.isFinite(post.beforeLat) ? { lat: post.beforeLat, lng: post.beforeLng } : group const target = Number.isFinite(post.beforeLat) ? { lat: post.beforeLat, lng: post.beforeLng } : group
if (target && distance(state.globe.pointOfView(), target) > 2500) if (target && distance(state.globe.pointOfView(), target) > 2500)
fly({ lat: target.lat, lng: target.lng, altitude: state.globe.pointOfView().altitude }, 900) fly({ lat: target.lat, lng: target.lng, altitude: state.globe.pointOfView().altitude }, 900)
@@ -828,9 +870,9 @@ function hover(key) {
const from = post.src === "place" || post.src === "event" const from = post.src === "place" || post.src === "event"
? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self) ? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self)
: state.self : state.self
state.globe.arcsData(from && distance(from, post) > 30 const toPost = from && distance(from, post) > 30 ? { startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng, label: post.label } : null
? [{ startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng }] state.globe.arcsData(toPost ? [toPost] : [])
: []) state.instruments?.setArc(toPost)
const camera = state.globe.pointOfView() const camera = state.globe.pointOfView()
if (distance(camera, post) > 2500) if (distance(camera, post) > 2500)
fly({ lat: post.lat, lng: post.lng, altitude: camera.altitude }, 900) fly({ lat: post.lat, lng: post.lng, altitude: camera.altitude }, 900)
@@ -851,6 +893,7 @@ function updateView() {
if (located.length === 0) { if (located.length === 0) {
state.view = null state.view = null
state.spreadKm = 0 state.spreadKm = 0
state.instruments?.setView(null, 0, [], { located: 0, visible: state.visible.size })
controls.autoRotate = !reducedMotion.matches controls.autoRotate = !reducedMotion.matches
wake() wake()
return return
@@ -870,6 +913,7 @@ function updateView() {
state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p))) state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p)))
mean.altitude = Math.min(3.6, (1.5 + state.spreadKm / 3500) * narrowness()) mean.altitude = Math.min(3.6, (1.5 + state.spreadKm / 3500) * narrowness())
state.view = mean state.view = mean
state.instruments?.setView(mean, state.spreadKm, located, { located: located.length, visible: state.visible.size })
if (!state.hovered) if (!state.hovered)
fly(mean, 1200) fly(mean, 1200)
} }
@@ -900,6 +944,7 @@ function readRenderer() {
const gl = state.globe.renderer().getContext() const gl = state.globe.renderer().getContext()
const info = gl.getExtension("WEBGL_debug_renderer_info") const info = gl.getExtension("WEBGL_debug_renderer_info")
state.renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER) state.renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)
state.instruments?.setFacts({ renderer: state.renderer })
} catch { } catch {
state.renderer = null state.renderer = null
} }
@@ -923,6 +968,7 @@ function countFrames() {
state.fps = state.running ? state.frames : 0 state.fps = state.running ? state.frames : 0
state.frames = 0 state.frames = 0
settle() settle()
state.instruments?.setFps(state.fps, { idle: state.idle, paused: state.paused, autoRotate: !!state.globe?.controls().autoRotate })
}, 1000)) }, 1000))
} }
+967
View File
@@ -0,0 +1,967 @@
// The globe's nerd stats drawn where they belong (owner decision 2026-10-06: instruments in the scene, not a table): the
// Sun's ray to the point it stands over, the day/night line and the twilights, the declination and the noon meridian;
// a focused post's horizon, its Sun's height and its day; the tropics and the polar circles; the view's centre and
// reach and the posts in view; the Sun's track through the year; a small orrery with the Earth–Sun distance and the
// seasons; and the frame rate as a ring around the globe. Numbers are small chips beside their drawings.
//
// It costs nothing while the globe is idle: its geometry changes only where globe.js has already woken the globe (the
// Sun moving, the view flying, a hover, the theme, a resize), and the chips are placed after each frame globe.gl
// renders (scene.onAfterRender). three.js's classes come from objects globe.gl already made: the bundle does not expose
// THREE, and defining it would mix two copies. Where they can't be found, only the ring and the legend remain.
import { analemma, seasonsOf, sunAtPlace } from "./sun.js"
const RAD = Math.PI / 180
const R = 100//the globe's radius in scene units
const DoubleSide = 2//three.js's DoubleSide: the bundle does not expose THREE
const BANNER = 320//a canvas less tall than this is a phone's banner: the day/night line and the subsolar point only
const PERIHELION = 103//the Earth's heliocentric longitude at perihelion (the Sun's ≈283°; it drifts 1.7° a century)
const ECCENTRICITY = 0.117//the orbit's 0.0167, seven times: drawn true it would look like a circle
export function createInstruments(globe, host, options) {
const labels = options?.labels ?? {}
const language = options?.language || undefined
const colour = options?.colour
const text = (key, ...args) => (labels[key] ?? key).replace(/\{(\d)\}/g, (_, i) => args[+i] ?? "")
const decimals = digits => new Intl.NumberFormat(language, { minimumFractionDigits: digits, maximumFractionDigits: digits })
const fixed2 = decimals(2), fixed1 = decimals(1), whole = decimals(0), au = decimals(5)
const coordinates = (lat, lng) => `${fixed2.format(lat)}, ${fixed2.format(lng)}`
const overlay = document.createElement("div")
overlay.className = "globe-instruments"
overlay.setAttribute("aria-hidden", "true")
host.appendChild(overlay)
const ring = ringElement(overlay)
const legend = document.createElement("div")
legend.className = "globe-legend"
overlay.appendChild(legend)
const description = host.parentElement?.querySelector("[data-globe-view-description]") ?? null
let size = { width: host.clientWidth, height: host.clientHeight }
let full = size.height >= BANNER
let failed = false//the chips threw once: they stay where they are
let tries = 0//renders that looked for three.js's classes; globe.gl adds its globe on its first update, after this
let frames = 0
let cameraText = ""
let cameraAt = 0
const facts = { points: 0, posts: 0, servers: 0, cdnGroups: 0, countries: 0, named: 0, borders: 0, night: false, anisotropy: 0, renderer: null }
const fps = { value: 0, idle: false, paused: false, autoRotate: false }
const chips = []
let placed = []
// what is drawn, and what it says: built on the first render that finds the globe (see attach)
const scene = globe.scene()
let three = null, root = null, parts = null, tags = null, missing = null
const previous = scene.onAfterRender
scene.onAfterRender = (...args) => {
previous?.(...args)
frame()
}
const before = scene.onBeforeRender
scene.onBeforeRender = (...args) => {
before?.(...args)
if (!parts && tries >= 0)
attach()
placeOrrery()
}
let sun = null, point = null, focus = null, view = null, spreadKm = 0, arc = null, track = [], lastView = null, orreryBox = null
let legendBox = null
const Vector3 = globe.camera().position.constructor
const temp = new Vector3()
const temp2 = new Vector3()
function buildParts() {
const lines = alpha => new three.LineBasicMaterial({ transparent: true, opacity: alpha, depthWrite: false })
const fills = (alpha, side = 0) => new three.MeshBasicMaterial({ transparent: true, opacity: alpha, depthWrite: false, side })
const hud = material => {
material.depthTest = false
return material
}
const part = {}
// the Sun: built around +Z, pointed at the subsolar point
part.sunRig = group("YXZ")
part.marker = segments(dashed(circle(1.5, R * 1.003, 48), false), lines(0.95), "sun")
part.dot = mesh(sphere(0.9, 12, 8), fills(1), "sun")
part.dot.position.set(0, 0, R * 1.004)
part.ray = polyline([v(0, 0, R * 1.004), v(0, 0, R * 1.45)], lines(0.9), "sun")
part.beam = mesh(beam(0.6, 2.6, R, R * 1.45, 10), fills(0.16, DoubleSide), "sun")
part.bead = mesh(sphere(2.2, 16, 12), fills(0.95), "sun")
part.bead.position.set(0, 0, R * 1.45)
part.terminator = mesh(band(89.825, 90.175, R * 1.0025, 180), fills(0.55, DoubleSide), "white")
part.twilight = [84, 96, 102, 108].map(angle => segments(dashed(circle(angle, R * 1.002, 180), true), lines(0.6), "twilight"))
part.meridian = segments(dashed(greatCircleX(R * 1.002, 180), true), lines(0.45), "sun")
part.sunRig.add(part.marker, part.dot, part.ray, part.beam, part.bead, part.terminator, ...part.twilight, part.meridian)
// the declination: the subsolar meridian from the equator to the Sun's latitude
part.meridianRig = group("XYZ")
part.declination = polyline(arcPoints(0, 0, 24), lines(0.9), "sun")
part.meridianRig.add(part.declination)
// the Earth's own lines: tropics, polar circles, the CDN ring at 68°N
part.earthRig = group("XYZ")
part.tropics = [23.44, -23.44, 66.56, -66.56].map(lat => segments(dashed(latitudeCircle(lat, R * 1.0015, 240), true), lines(0.22), "white"))
part.cdnRing = segments(dashed(latitudeCircle(68, R * 1.0015, 240), true), lines(0.55), "server")
part.earthRig.add(...part.tropics, part.cdnRing)
// the Sun's year: where it stands over at this hour each day, the stretch to the next equinox or solstice brighter
part.year = polyline(Array.from({ length: 366 }, () => v(0, 0, R)), lines(0.3), "sun")
part.yearNext = polyline(Array.from({ length: 366 }, () => v(0, 0, R)), lines(0.85), "sun")
part.yearRig = group("XYZ")
part.yearRig.add(part.year, part.yearNext)
// a focused post's place: built around +Z, pointed at it; its Sun line, elevation and day in the scene's frame
part.placeRig = group("YXZ")
part.horizon = segments(dashed(circle(2, R * 1.002, 48), false), lines(0.9), "place")
part.pole = polyline([v(0, 0, R * 1.001), v(0, 0, R * 1.07)], lines(0.9), "place")
part.placeRig.add(part.horizon, part.pole)
part.placeWorld = group("XYZ")
part.sunLine = polyline([v(0, 0, 0), v(0, 0, 1)], lines(0.95), "sun")
part.sunLineBelow = segments(dashed(Array.from({ length: 13 }, () => v(0, 0, 0)), false), lines(0.6), "sun")
part.elevation = polyline(Array.from({ length: 17 }, () => v(0, 0, 0)), lines(0.9), "place")
part.dayLit = polyline(Array.from({ length: 121 }, () => v(0, 0, 0)), lines(0.95), "sun")
part.dayDark = segments(Array.from({ length: 240 }, () => v(0, 0, 0)), lines(0.4), "twilight")
part.placeWorld.add(part.sunLine, part.sunLineBelow, part.elevation, part.dayLit, part.dayDark)
// the view: its centre, its reach, and a ring around each placed post in view
part.viewRig = group("YXZ")
part.cross = segments([v(-2.2, 0, R * 1.002), v(2.2, 0, R * 1.002), v(0, -2.2, R * 1.002), v(0, 2.2, R * 1.002)], lines(0.9), "server")
part.spread = segments(dashed(circle(10, R * 1.002, 128), true), lines(0.65), "server")
part.viewRig.add(part.cross, part.spread)
part.inView = segments(Array.from({ length: 32 * 24 }, () => v(0, 0, 0)), lines(0.85), "place")
part.inView.geometry.setDrawRange(0, 0)
// the orrery: the Sun, the orbit (eccentricity drawn sevenfold), the Earth lit on its Sun's side with its axis
// fixed in space, ticks at the equinoxes and solstices; placed in the camera's corner before each frame
part.orrery = group("XYZ")
part.orrerySun = mesh(sphere(0.17, 16, 12), hud(fills(1)), "sun")
part.orbit = polyline(Array.from({ length: 145 }, (_, i) => orbitPoint(i * 2.5)), hud(lines(0.6)), "content")
part.ticks = segments([0, 90, 180, 270].flatMap(l => [orbitPoint(l, 0.93), orbitPoint(l, 1.07)]), hud(lines(0.8)), "content")
part.earth = group("XYZ")
part.earthDay = mesh(sphere(0.1, 16, 8, Math.PI / 2), hud(fills(1)), "server")
part.earthNight = mesh(sphere(0.1, 16, 8, Math.PI / 2), hud(fills(0.9)), "night")
part.earthNight.rotation.x = Math.PI
part.earth.add(part.earthDay, part.earthNight)
const tilt = 23.44 * RAD
part.axis = polyline([v(-Math.sin(tilt) * 0.2, 0, -Math.cos(tilt) * 0.2), v(Math.sin(tilt) * 0.2, 0, Math.cos(tilt) * 0.2)], hud(lines(0.9)), "content")
part.orrery.add(part.orrerySun, part.orbit, part.ticks, part.earth, part.axis)
part.orrery.traverse(o => o.renderOrder = 20)
part.orreryTilt = new (globe.camera().quaternion.constructor)().setFromAxisAngle(v(1, 0, 0), -1.05)
root.add(part.sunRig, part.meridianRig, part.earthRig, part.yearRig, part.placeRig, part.placeWorld, part.viewRig, part.inView, part.orrery)
return part
}
// the chips: what each says, where, and which goes first. Where is a point in the scene (hidden behind the globe), the
// same never hidden ("always"), or a point on screen whose chip starts there ("screen", the orrery's); a lifted chip
// sits that many pixels above its point, clear of what is drawn there.
const chip = (name, priority, anchor, mode = "scene", lift = 0) => {
const element = document.createElement("div")
element.className = `globe-tag globe-tag-${name}`
element.style.visibility = "hidden"
overlay.appendChild(element)
const made = { name, priority, anchor, mode, lift, element, text: "", width: 0, height: 0, x: NaN, y: NaN, shown: false }
chips.push(made)
return made
}
const say = (target, value) => {
value ??= ""
if (target.text === value)
return
target.text = value
target.element.textContent = value
target.width = target.element.offsetWidth
target.height = target.element.offsetHeight
}
const ringTag = chip("ring", 0, null)
function makeTags() {
const made = {
// the orrery's, first: nothing else is drawn in its corner
distance: chip("orrery", 1, () => belowOrrery(0), "screen"),
seasons: chip("orrery", 1, () => belowOrrery(1), "screen"),
next: chip("orrery", 2, () => belowOrrery(2), "screen"),
apsis: chip("orrery", 3, () => belowOrrery(3), "screen"),
scale: chip("orrery-faint", 3, () => orreryBox ? { x: orreryBox.right + 4, y: orreryBox.middle } : null, "screen"),
subsolar: chip("sun", 4, () => worldOf(parts.sunRig, 0, 0, R * 1.004), "scene", 12),
place: chip("place", 5, () => focus && Number.isFinite(focus.lat) ? coords(focus.lat, focus.lng, 0.002) : null, "scene", 16),
terminator: chip("white", 6, () => nearestOn(parts.sunRig, 90, R * 1.004)),
view: chip("server", 9, () => view ? worldOf(parts.viewRig, 0, -3.2, R * 1.004) : null),
spread: chip("server", 10, () => view && spreadKm > 0 ? nearestOn(parts.viewRig, Math.min(80, spreadKm / 6371 / RAD), R * 1.004) : null),
arc: chip("server", 11, () => arcTop()),
irradiance: chip("sun", 12, () => worldOf(parts.sunRig, 0, 0, R * 1.52)),
declination: chip("sun", 13, () => sun ? worldOf(parts.meridianRig, 0, R * Math.sin(sun.lat * RAD / 2), R * Math.cos(sun.lat * RAD / 2)) : null),
golden: chip("twilight", 14, () => nearestOn(parts.sunRig, 84, R * 1.004)),
civil: chip("twilight", 15, () => nearestOn(parts.sunRig, 96, R * 1.004)),
nautical: chip("twilight", 16, () => nearestOn(parts.sunRig, 102, R * 1.004)),
astronomical: chip("twilight", 17, () => nearestOn(parts.sunRig, 108, R * 1.004)),
noon: chip("sun", 18, () => worldOf(parts.sunRig, 0, R * Math.sin(25 * RAD), R * Math.cos(25 * RAD))),
midnight: chip("sun", 19, () => worldOf(parts.sunRig, 0, R * Math.sin(205 * RAD), R * Math.cos(205 * RAD))),
yearNext: chip("sun", 20, () => track.length > 0 ? coords(track.at(-1).lat, track.at(-1).lng, 0.02) : null),
year: chip("sun-faint", 21, () => yearTop()),
cancer: chip("white", 22, () => cameraLongitude(23.44)),
capricorn: chip("white", 23, () => cameraLongitude(-23.44)),
arctic: chip("white", 24, () => cameraLongitude(66.56)),
antarctic: chip("white", 25, () => cameraLongitude(-66.56)),
cdn: chip("server", 26, () => facts.cdnGroups > 0 ? cameraLongitude(68) : null)
}
chips.sort((a, b) => a.priority - b.priority)
say(made.terminator, text("day/night line"))
say(made.golden, text("golden hour"))
say(made.civil, text("civil twilight"))
say(made.nautical, text("nautical twilight"))
say(made.astronomical, text("astronomical twilight"))
say(made.noon, text("solar noon"))
say(made.midnight, text("solar midnight"))
say(made.year, text("this hour, all year"))
say(made.cancer, `${text("Tropic of Cancer")} · ${text("axial tilt")} ${fixed2.format(23.44)}°`)
say(made.capricorn, text("Tropic of Capricorn"))
say(made.arctic, `${text("Arctic Circle")} ${fixed2.format(66.56)}°`)
say(made.antarctic, text("Antarctic Circle"))
say(made.scale, text("not to scale"))
return made
}
// three.js's classes, once globe.gl has put its globe in the scene: the parts built and told what globe.js already
// said. Looked for before each render until found; after 120 renders without them only the ring and the legend remain.
function attach() {
const found = classesOf(globe)
if (typeof found === "string") {
missing = found
if (++tries < 120)
return
tries = -1
console.warn(`globe.instruments: ${missing}; only the ring and the legend remain`)
return
}
three = found
try {
root = new three.Group()
parts = buildParts()
scene.add(root)
tags = makeTags()
setColours()
refresh()
if (point)
setSun(point)
if (sun)
setMoment(sun, focus)
if (lastView)
setView(...lastView)
setArc(arc)
setPoints({})
} catch (e) {
tries = -1
parts = null
root?.removeFromParent()
console.error(e)
window.logFromJs?.(e.message, "globe.instruments.attach")
}
}
setSize(size.width, size.height)
// --- what globe.js tells it ---
function setSize(width, height) {
size = { width, height }
full = height >= BANNER
overlay.dataset.detail = full ? "full" : "banner"
ring.svg.style.display = full ? "" : "none"
legend.style.display = full ? "" : "none"
measureLegend()
refresh()
}
// what shows: in a banner the day/night line and the subsolar point only; the place and the view when there are some
function refresh() {
if (!parts)
return
for (const object of [parts.ray, parts.beam, parts.bead, ...parts.twilight, parts.meridian, parts.meridianRig, parts.earthRig, parts.yearRig,
parts.inView, parts.orrery])
object.visible = full
parts.placeRig.visible = parts.placeWorld.visible = full && !!focus && Number.isFinite(focus.lat)
parts.viewRig.visible = full && !!view
parts.cdnRing.visible = facts.cdnGroups > 0
}
// the subsolar point as it eases towards the Sun's (every frame while it travels)
function setSun(eased) {
point = eased
if (!parts)
return
parts.sunRig.rotation.set(-eased.lat * RAD, eased.lng * RAD, 0, "YXZ")
parts.meridianRig.rotation.set(0, eased.lng * RAD, 0)
rewrite(parts.declination, arcPoints(0, eased.lat, 24))
drawPlace()
}
// a new moment (now, or the focused post's) and the place it is focused on
function setMoment(moment, focused) {
sun = moment
focus = focused
if (!parts)
return
say(tags.subsolar, `${text("subsolar point")} ${coordinates(moment.lat, moment.lng)}`)
const deviation = (moment.irradiance - 1) * 100
say(tags.irradiance, `${text("irradiance")} ${whole.format(moment.irradiance * 1361)} W/m² (${deviation >= 0 ? "+" : "−"}${fixed1.format(Math.abs(deviation))}%)`)
say(tags.declination, `${text("declination")} ${moment.lat >= 0 ? "+" : "−"}${fixed2.format(Math.abs(moment.lat))}°`)
// the year from this moment, at this hour
const seasons = seasonsOf(moment)
track = analemma(moment.date)
rewrite(parts.year, track.map(p => toVector(coords(p.lat, p.lng, 0.0025))))
// the nearest and farthest the Earth comes in the year ahead, to the day: whichever is first
let nearest = 0, farthest = 0
track.forEach((p, day) => {
if (p.distance < track[nearest].distance)
nearest = day
if (p.distance > track[farthest].distance)
farthest = day
})
say(tags.apsis, nearest <= farthest
? text("{0} in {1} days", text("perihelion"), whole.format(nearest))
: text("{0} in {1} days", text("aphelion"), whole.format(farthest)))
const days = Math.max(1, Math.min(365, seasons.nextEventDays))
track = track.slice(0, days + 1)
rewrite(parts.yearNext, track.map(p => toVector(coords(p.lat, p.lng, 0.0025))))
parts.yearNext.geometry.setDrawRange(0, track.length)
say(tags.yearNext, eventName(seasons.nextEvent))
// the orrery: the Earth where it is on its orbit (the Sun's ecliptic longitude, seen from the other side)
const earthAt = orbitXYZ(moment.eclipticLongitude + 180, 1)
parts.earth.position.set(...earthAt)
parts.axis.position.set(...earthAt)
say(tags.distance, `${au.format(moment.distance)} AU · ${whole.format(moment.distance * 149597870.7)} km`)
say(tags.seasons, text("{0} in the north, {1} in the south", seasonName(seasons.north), seasonName(seasons.south)))
say(tags.next, text("{0} in {1} days", eventName(seasons.nextEvent), whole.format(seasons.nextEventDays)))
const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(moment, focus.lat, focus.lng) : null
refresh()
if (place) {
parts.placeRig.rotation.set(-focus.lat * RAD, focus.lng * RAD, 0, "YXZ")
const phase = place.phase === "day" ? text("daylight") : place.phase === "night" ? text("night")
: text("{0}, {1}", text(`${place.phase === "golden" ? "golden hour" : `${place.phase} twilight`}`), text(place.morning ? "morning" : "evening"))
const day = place.dayLength >= 24 ? text("the Sun never sets") : place.dayLength <= 0 ? text("the Sun never rises")
: `${text("day length")} ${Math.floor(place.dayLength)} h ${String(Math.round(place.dayLength % 1 * 60)).padStart(2, "0")} min`
say(tags.place, [
[focus.label || coordinates(focus.lat, focus.lng), phase, text("solar time {0}", place.solarTime)].join(" · "),
`${text("elevation")} ${place.elevation >= 0 ? "" : "−"}${fixed1.format(Math.abs(place.elevation))}° · ${day}`
].join("\n"))
tags.place.element.dataset.phase = place.phase
} else
say(tags.place, "")
drawPlace()
}
// the view's centre and reach (null with no placed post in view), and the placed posts in view
function setView(centre, reachKm, inView, counts) {
lastView = [centre, reachKm, inView, counts]
view = centre
spreadKm = reachKm
if (description)
description.textContent = centre
? `${text("centre of view")} ${coordinates(centre.lat, centre.lng)}; ${text("spread")} ${whole.format(reachKm)} km; ${text("in view")} ${counts.located}/${counts.visible} ${text("placed")}`
: `${text("in view")} ${counts.located}/${counts.visible} ${text("placed")}`
if (!parts)
return
refresh()
if (centre) {
parts.viewRig.rotation.set(-centre.lat * RAD, centre.lng * RAD, 0, "YXZ")
rewrite(parts.spread, dashed(circle(Math.max(0.5, Math.min(80, reachKm / 6371 / RAD)), R * 1.002, 128), true))
say(tags.view, `${text("centre of view")} ${coordinates(centre.lat, centre.lng)} · ${text("in view")} ${counts.located}/${counts.visible}`)
say(tags.spread, `${text("spread")} ${whole.format(reachKm)} km`)
} else {
say(tags.view, "")
say(tags.spread, "")
}
// a small ring around each placed post in view
const points = parts.inView.geometry.attributes.position
let n = 0
for (const post of inView.slice(0, 32))
for (const [a, b] of ringSegments(post.lat, post.lng, 1, 12)) {
points.setXYZ(n++, a.x, a.y, a.z)
points.setXYZ(n++, b.x, b.y, b.z)
}
points.needsUpdate = true
parts.inView.geometry.setDrawRange(0, n)
}
// the hovered post's arc ({ startLat, startLng, endLat, endLng, label }), or none
function setArc(hovered) {
arc = hovered
if (!tags)
return
say(tags.arc, hovered ? `${hovered.label ? `${hovered.label} · ` : ""}${whole.format(greatCircleKm(hovered))} km` : "")
}
function setPoints(counts) {
Object.assign(facts, counts)
refresh()
if (tags)
say(tags.cdn, facts.cdnGroups > 0 ? `${text("CDN groups")} ${facts.cdnGroups}` : "")
drawLegend()
}
function setFacts(more) {
Object.assign(facts, more)
drawLegend()
}
// the frame rate, every second; the DOM only, so an idle globe stays idle
function setFps(value, state) {
Object.assign(fps, { value }, state)
const pace = fps.paused ? "paused" : fps.idle ? "idle" : value >= 50 ? "good" : value >= 30 ? "fair" : "poor"
ring.svg.dataset.pace = pace
ring.value.style.strokeDasharray = pace === "idle" || pace === "paused" ? `0 ${ring.length}` : `${ring.length * Math.min(1, value / 60)} ${ring.length}`
const rate = fps.paused ? text("paused") : fps.idle ? text("idle") : `${value} fps`
say(ringTag, [rate, fps.autoRotate ? text("auto-rotate") : null, cameraText].filter(Boolean).join(" · "))
}
function setColours() {
if (!parts || !colour)
return
const paint = kind => {
const rgb = colour(kind, 1).match(/[\d.]+/g)?.slice(0, 3).join(",") ?? "128,128,128"
return `rgb(${rgb})`
}
root.traverse(object => {
if (object.userData.paint && object.material?.color)
object.material.color.setStyle(object.userData.paint === "white" ? "rgb(255,255,255)" : paint(object.userData.paint))
})
}
// the boxes the chips took, which the country names keep clear of
function boxes() {
return placed
}
function summary() {
let error = null
if (parts && point) {
// as the rig stands now, not as the last frame drew it
parts.sunRig.updateMatrixWorld(true)
const at = worldOf(parts.sunRig, 0, 0, R)
const want = globe.getCoords(point.lat, point.lng, 0)
error = Math.hypot(at.x - want.x, at.y - want.y, at.z - want.z)
}
return { built: !!parts, missing: parts ? null : missing, tries, detail: full ? "full" : "banner", frames, chips: chips.filter(c => c.shown).length, subsolarError: error }
}
function dispose() {
tries = -1
root?.traverse(object => {
object.geometry?.dispose?.()
object.material?.dispose?.()
})
root?.removeFromParent()
scene.onAfterRender = () => { }
scene.onBeforeRender = () => { }
overlay.remove()
}
// --- after each frame globe.gl renders: the chips placed, and the ring around the globe ---
function frame() {
if (failed)
return
try {
frames++
const camera = globe.camera()
const now = performance.now()
if (now - cameraAt > 100) {
cameraAt = now
const pov = globe.pointOfView()
const next = `${text("camera")} ${coordinates(pov.lat, pov.lng)} @ ${fixed2.format(pov.altitude)}`
if (next !== cameraText) {
cameraText = next
setFps(fps.value, fps)
}
}
const taken = [[0, 0, size.width, 44]]
if (orreryBox)
taken.push(Object.assign([0, orreryBox.top - 4, orreryBox.right, orreryBox.bottom + 4], { orrery: true }))
if (legendBox)
taken.push(legendBox)
for (const target of chips) {
if (target === ringTag)
continue
const at = full && target.text && target.anchor ? target.anchor() : null
if (!at || (target.mode === "scene" && behindGlobe(at, camera.position))) {
hide(target)
continue
}
let x = 0, y = 0
if (target.mode === "screen") {
x = at.x + target.width / 2
y = at.y
} else {
temp2.copy(at).project(camera)
if (temp2.z > 1) {
hide(target)
continue
}
x = (temp2.x + 1) / 2 * size.width
y = (1 - temp2.y) / 2 * size.height - (target.lift ? target.lift + target.height / 2 : 0)
// a point on the canvas whose chip would run past its edge: the chip slides back inside
if (x >= 0 && x <= size.width)
x = Math.max(target.width / 2 + 3, Math.min(size.width - target.width / 2 - 3, x))
}
const box = [x - target.width / 2 - 2, y - target.height / 2 - 1, x + target.width / 2 + 2, y + target.height / 2 + 1]
// the orrery's own chips may sit on it
const own = target.name.startsWith("orrery")
if (box[0] < 0 || box[1] < 0 || box[2] > size.width || box[3] > size.height || taken.some(b => !(own && b.orrery) && overlap(b, box))) {
hide(target)
continue
}
taken.push(box)
show(target, x, y)
}
placeRing(camera, taken)
placed = taken
} catch (e) {
failed = true
console.error(e)
window.logFromJs?.(e.message, "globe.instruments.frame")
}
}
// the ring hugs the globe's outline on screen: its centre projected, its radius the globe's apparent one
function placeRing(camera, taken) {
if (!full)
return
temp2.set(0, 0, 0).project(camera)
const cx = (temp2.x + 1) / 2 * size.width, cy = (1 - temp2.y) / 2 * size.height
const distance = camera.position.length()
const half = Math.asin(Math.min(1, R / distance))
const radius = size.height / 2 * Math.tan(half) / Math.tan(camera.fov * RAD / 2) + 6
if (Math.abs(radius - ring.radius) > 0.5 || Math.abs(cx - ring.cx) > 0.5 || Math.abs(cy - ring.cy) > 0.5) {
ring.radius = radius
ring.cx = cx
ring.cy = cy
for (const circleElement of [ring.track, ring.value]) {
circleElement.setAttribute("cx", cx.toFixed(1))
circleElement.setAttribute("cy", cy.toFixed(1))
circleElement.setAttribute("r", radius.toFixed(1))
}
ring.value.setAttribute("transform", `rotate(-90 ${cx.toFixed(1)} ${cy.toFixed(1)})`)
ring.length = 2 * Math.PI * radius
setFps(fps.value, fps)
}
// its words at its foot, or above the globe's foot when the ring runs off the canvas
const y = Math.min(cy + radius + 4 + ringTag.height / 2, size.height - ringTag.height / 2 - 2)
const box = [cx - ringTag.width / 2, y - ringTag.height / 2, cx + ringTag.width / 2, y + ringTag.height / 2]
if (ringTag.text && !taken.some(b => overlap(b, box))) {
taken.push(box)
show(ringTag, cx, y)
} else
hide(ringTag)
}
// the orrery in the camera's top-left corner, tilted towards the reader, before each frame
// the orrery in the top left corner, below the moment's caption, sized in pixels: its orbit seen from 60° above the
// ecliptic is half as tall as it is wide; its chips stack under it
function placeOrrery() {
if (!parts || !parts.orrery.visible) {
orreryBox = null
return
}
const camera = globe.camera()
const depth = 30
const perPixel = 2 * depth * Math.tan(camera.fov * RAD / 2) / size.height
const radius = Math.max(52, Math.min(96, size.width * 0.15))
const cx = 12 + radius * 1.25, cy = 56 + radius * 0.6
orreryBox = { left: 12, top: cy - radius * 0.6, right: cx + radius * 1.25, bottom: cy + radius * 0.6, middle: cy }
parts.orrery.position.set((cx - size.width / 2) * perPixel, (size.height / 2 - cy) * perPixel, -depth).applyQuaternion(camera.quaternion).add(camera.position)
parts.orrery.quaternion.copy(camera.quaternion).multiply(parts.orreryTilt)
parts.orrery.scale.setScalar(radius * perPixel)
parts.orrery.updateMatrixWorld(true)
// the Earth's lit half faces the Sun
parts.orrerySun.getWorldPosition(temp)
parts.earth.lookAt(temp)
parts.earth.updateMatrixWorld(true)
}
// --- the focused place's Sun line, elevation and day, as the Sun eases ---
function drawPlace() {
if (!parts || !point || !focus || !Number.isFinite(focus.lat) || !sun)
return
const n = unitOf(focus.lat, focus.lng)
const s = unitOf(point.lat, point.lng)
const at = n.clone().multiplyScalar(R * 1.002)
const up = n.dot(s)
const elevation = Math.asin(Math.max(-1, Math.min(1, up))) / RAD
const tip = s.clone().multiplyScalar(9).add(at)
rewrite(parts.sunLine, [at, tip])
parts.sunLine.visible = elevation >= 0
parts.sunLineBelow.visible = elevation < 0
if (elevation < 0)
rewrite(parts.sunLineBelow, Array.from({ length: 13 }, (_, i) => s.clone().multiplyScalar(9 * i / 12).add(at)))
// the arc from the horizon to the Sun's direction, 6 units out
const flat = s.clone().addScaledVector(n, -up)
if (flat.lengthSq() < 1e-6)
flat.copy(perpendicular(n))
flat.normalize()
rewrite(parts.elevation, Array.from({ length: 17 }, (_, i) => {
const t = elevation * RAD * i / 16
return flat.clone().multiplyScalar(Math.cos(t) * 6).addScaledVector(n, Math.sin(t) * 6).add(at)
}))
// the day at the place's latitude: lit around the Sun's meridian for the day's length, dark dashes for the night
const place = sunAtPlace(sun, focus.lat, focus.lng)
const half = Math.max(0, Math.min(180, place.dayLength * 7.5))
rewrite(parts.dayLit, Array.from({ length: 121 }, (_, i) => toVector(coords(focus.lat, point.lng - half + 2 * half * i / 120, 0.003))))
parts.dayLit.visible = half > 0
const dark = []
const nightSpan = 360 - 2 * half
for (let i = 0; i < 120; i++) {
const a = point.lng + half + nightSpan * i / 120
const b = point.lng + half + nightSpan * (i + 0.5) / 120
dark.push(toVector(coords(focus.lat, a, 0.003)), toVector(coords(focus.lat, b, 0.003)))
}
rewrite(parts.dayDark, dark)
parts.dayDark.visible = nightSpan > 0
}
// --- anchors ---
function arcTop() {
if (!arc)
return null
const a = unitOf(arc.startLat, arc.startLng), b = unitOf(arc.endLat, arc.endLng)
const middle = a.clone().add(b)
if (middle.lengthSq() < 1e-6)
return null
middle.normalize()
const angle = Math.acos(Math.max(-1, Math.min(1, a.dot(b))))
return temp.copy(middle).multiplyScalar(R * (1 + angle / 2 * 0.4 * 1.1))
}
function yearTop() {
const positions = parts.year.geometry.attributes.position
let best = 0
for (let i = 1; i < positions.count; i++)
if (positions.getY(i) > positions.getY(best))
best = i
return temp.set(positions.getX(best), positions.getY(best) * 1.004, positions.getZ(best))
}
// the orrery's chips, one under another below it
function belowOrrery(slot) {
return orreryBox ? { x: orreryBox.left, y: orreryBox.bottom + 12 + slot * 20 } : null
}
function cameraLongitude(lat) {
return coords(lat, globe.pointOfView().lng, 0.004)
}
// the point of a circle (polar angle from the rig's +Z) nearest the camera
function nearestOn(rig, angle, radius) {
temp.copy(globe.camera().position)
rig.worldToLocal(temp)
const phi = Math.atan2(temp.y, temp.x)
return worldOf(rig, radius * Math.sin(angle * RAD) * Math.cos(phi), radius * Math.sin(angle * RAD) * Math.sin(phi), radius * Math.cos(angle * RAD))
}
function worldOf(object, x, y, z) {
return temp.set(x, y, z).applyMatrix4(object.matrixWorld)
}
function coords(lat, lng, altitude) {
const at = globe.getCoords(lat, lng, altitude)
return temp.set(at.x, at.y, at.z)
}
function toVector(at) {
return v(at.x, at.y, at.z)
}
// three-globe's frame: x = cos lat · sin lng, y = sin lat, z = cos lat · cos lng
function unitOf(lat, lng) {
return v(Math.cos(lat * RAD) * Math.sin(lng * RAD), Math.sin(lat * RAD), Math.cos(lat * RAD) * Math.cos(lng * RAD))
}
function perpendicular(n) {
return Math.abs(n.y) < 0.9 ? v(0, 1, 0).cross(n) : v(1, 0, 0).cross(n)
}
function ringSegments(lat, lng, radiusDeg, steps) {
const n = unitOf(lat, lng)
const e = perpendicular(n).normalize()
const u = n.clone().cross(e)
const a = radiusDeg * RAD
const points = Array.from({ length: steps }, (_, i) => {
const t = 2 * Math.PI * i / steps
return n.clone().multiplyScalar(Math.cos(a)).addScaledVector(e, Math.sin(a) * Math.cos(t)).addScaledVector(u, Math.sin(a) * Math.sin(t)).multiplyScalar(R * 1.003)
})
return points.map((p, i) => [p, points[(i + 1) % steps]])
}
function greatCircleKm(a) {
const p = unitOf(a.startLat, a.startLng), q = unitOf(a.endLat, a.endLng)
return 6371 * Math.acos(Math.max(-1, Math.min(1, p.dot(q))))
}
function behindGlobe(point, camera) {
const dx = point.x - camera.x, dy = point.y - camera.y, dz = point.z - camera.z
const a = dx * dx + dy * dy + dz * dz
const b = 2 * (camera.x * dx + camera.y * dy + camera.z * dz)
const c = camera.x * camera.x + camera.y * camera.y + camera.z * camera.z - R * R * 0.998
const discriminant = b * b - 4 * a * c
if (discriminant <= 0)
return false
const t = (-b - Math.sqrt(discriminant)) / (2 * a)
return t > 0 && t < 0.999
}
function show(target, x, y) {
if (!target.shown) {
target.shown = true
target.element.style.visibility = ""
}
// a chip placed for the first time has no x nor y (NaN), which no comparison is true of
if (!(Math.abs(x - target.x) < 0.5 && Math.abs(y - target.y) < 0.5)) {
target.x = x
target.y = y
target.element.style.transform = `translate(${(x - target.width / 2).toFixed(1)}px, ${(y - target.height / 2).toFixed(1)}px)`
}
}
function hide(target) {
if (target.shown) {
target.shown = false
target.element.style.visibility = "hidden"
}
}
function drawLegend() {
const lines = [
`${text("points")} ${facts.points} · ${text("posts")} ${facts.posts} · ${text("servers")} ${facts.servers}${facts.cdnGroups > 0 ? ` · ${text("CDN groups")} ${facts.cdnGroups}` : ""}`,
[`${text(facts.night ? "day and night maps" : "day map")} 4096×2048`, facts.anisotropy ? text("{0}× anisotropic", facts.anisotropy) : null,
facts.countries ? `${text("countries")} ${facts.named}/${facts.countries}` : null,
facts.borders ? `${text("borders")} ${facts.borders} ${text("lines")}` : null].filter(Boolean).join(" · "),
facts.renderer ?? ""
].filter(Boolean)
const value = lines.join("\n")
if (legend.textContent === value)
return
legend.textContent = value
measureLegend()
}
// the legend's box, which the chips keep clear of; measured when its words or the canvas change, never per frame
function measureLegend() {
legendBox = full && legend.textContent ? [legend.offsetLeft, legend.offsetTop - 4, legend.offsetLeft + legend.offsetWidth, size.height] : null
}
function seasonName(season) {
return text(season)
}
function eventName(next) {
return text({ "march-equinox": "March equinox", "june-solstice": "June solstice", "september-equinox": "September equinox" }[next] ?? "December solstice")
}
// --- geometry, built with three's own classes ---
function v(x, y, z) {
return new three.Vector3(x, y, z)
}
function group(order) {
const made = new three.Group()
made.rotation.order = order
return made
}
function painted(object, kind) {
object.userData.paint = kind
object.frustumCulled = false
return object
}
function polyline(points, material, kind) {
return painted(new three.Line(new three.BufferGeometry().setFromPoints(points), material), kind)
}
function segments(points, material, kind) {
return painted(new three.LineSegments(new three.BufferGeometry().setFromPoints(points), material), kind)
}
function mesh(geometry, material, kind) {
return painted(new three.Mesh(geometry, material), kind)
}
// a line's points written in place (same count), or its geometry made again when the count changed
function rewrite(object, points) {
const positions = object.geometry.attributes.position
if (positions && positions.count === points.length) {
points.forEach((p, i) => positions.setXYZ(i, p.x, p.y, p.z))
positions.needsUpdate = true
return
}
object.geometry.dispose()
object.geometry = new three.BufferGeometry().setFromPoints(points)
}
// a circle at an angle from +Z
function circle(angle, radius, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const t = 2 * Math.PI * i / steps
return v(radius * Math.sin(angle * RAD) * Math.cos(t), radius * Math.sin(angle * RAD) * Math.sin(t), radius * Math.cos(angle * RAD))
})
}
// the great circle through +Z and +Y (x = 0): in the Sun's rig, the meridian of noon and midnight
function greatCircleX(radius, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const t = 2 * Math.PI * i / steps
return v(0, radius * Math.sin(t), radius * Math.cos(t))
})
}
function latitudeCircle(lat, radius, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const lng = 2 * Math.PI * i / steps
return v(radius * Math.cos(lat * RAD) * Math.sin(lng), radius * Math.sin(lat * RAD), radius * Math.cos(lat * RAD) * Math.cos(lng))
})
}
// the meridian at longitude 0 from one latitude to another
function arcPoints(from, to, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const lat = (from + (to - from) * i / steps) * RAD
return v(0, R * 1.003 * Math.sin(lat), R * 1.003 * Math.cos(lat))
})
}
// a polyline as segments, every other one left out (or, solid, every one kept)
function dashed(points, gaps) {
const pairs = []
for (let i = 0; i < points.length - 1; i += gaps ? 2 : 1)
pairs.push(points[i], points[i + 1])
return pairs
}
function setIndexed(points, index) {
const geometry = new three.BufferGeometry().setFromPoints(points)
geometry.setIndex(index)
return geometry
}
// a band between two angles from +Z
function band(inner, outer, radius, steps) {
const points = []
const index = []
for (let i = 0; i <= steps; i++) {
const t = 2 * Math.PI * i / steps
for (const angle of [inner, outer])
points.push(v(radius * Math.sin(angle * RAD) * Math.cos(t), radius * Math.sin(angle * RAD) * Math.sin(t), radius * Math.cos(angle * RAD)))
if (i < steps)
index.push(2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 1, 2 * i + 3, 2 * i + 2)
}
return setIndexed(points, index)
}
// a tapered tube along +Z
function beam(from, to, start, end, sides) {
const points = []
const index = []
for (let i = 0; i <= sides; i++) {
const t = 2 * Math.PI * i / sides
points.push(v(from * Math.cos(t), from * Math.sin(t), start), v(to * Math.cos(t), to * Math.sin(t), end))
if (i < sides)
index.push(2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 1, 2 * i + 3, 2 * i + 2)
}
return setIndexed(points, index)
}
// a sphere (or a cap of it, to a polar angle from +Z)
function sphere(radius, around, down, cap = Math.PI) {
const points = []
const index = []
for (let j = 0; j <= down; j++) {
const polar = cap * j / down
for (let i = 0; i <= around; i++) {
const t = 2 * Math.PI * i / around
points.push(v(radius * Math.sin(polar) * Math.cos(t), radius * Math.sin(polar) * Math.sin(t), radius * Math.cos(polar)))
}
}
for (let j = 0; j < down; j++)
for (let i = 0; i < around; i++) {
const a = j * (around + 1) + i, b = a + around + 1
index.push(a, b, a + 1, b, b + 1, a + 1)
}
return setIndexed(points, index)
}
// the orbit's point at a heliocentric longitude, from the Sun at the focus (the ellipse's scale is 1)
function orbitXYZ(longitude, out = 1) {
const anomaly = (longitude - PERIHELION) * RAD
const r = (1 - ECCENTRICITY * ECCENTRICITY) / (1 + ECCENTRICITY * Math.cos(anomaly)) * out
return [r * Math.cos(longitude * RAD), r * Math.sin(longitude * RAD), 0]
}
function orbitPoint(longitude, out = 1) {
return v(...orbitXYZ(longitude, out))
}
return { setSize, setSun, setMoment, setView, setArc, setPoints, setFacts, setFps, setColours, boxes, summary, dispose }
}
// three.js's classes, from objects globe.gl made: its globe mesh in its group, the graticules (a LineSegments with its
// LineBasicMaterial, added hidden), an empty Mesh for its default geometry and material, the camera's Vector3. Each is
// checked by three's own is* flags; what is missing, as words, when one isn't there.
function classesOf(globe) {
try {
const scene = globe.scene()
const globeMaterial = globe.globeMaterial()
let earth = null, graticules = null
scene.traverse(object => {
if (!earth && object.isMesh && object.material === globeMaterial)
earth = object
if (!graticules && object.isLineSegments)
graticules = object
})
if (!earth?.parent?.isGroup)
return "no globe mesh in a group"
if (!graticules?.material?.isLineBasicMaterial)
return "no graticules"
const Mesh = earth.constructor
const Group = earth.parent.constructor
const LineSegments = graticules.constructor
const Line = Object.getPrototypeOf(LineSegments)
const LineBasicMaterial = graticules.material.constructor
const probe = new Mesh()
const BufferGeometry = probe.geometry.constructor
const MeshBasicMaterial = probe.material.constructor
const Vector3 = globe.camera().position.constructor
const line = new Line()
const ok = line.isLine && !line.isLineSegments && new Group().isGroup && probe.isMesh && new BufferGeometry().isBufferGeometry
&& new MeshBasicMaterial().isMeshBasicMaterial && new Vector3().isVector3
for (const made of [probe, line]) {
made.geometry.dispose()
made.material.dispose()
}
return ok ? { Mesh, Group, LineSegments, Line, LineBasicMaterial, BufferGeometry, MeshBasicMaterial, Vector3 } : "a class without its is* flag"
} catch (e) {
console.error(e)
return e.message
}
}
// the frame-rate ring: a track and a value arc, an SVG over the canvas
function ringElement(overlay) {
const ns = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(ns, "svg")
svg.setAttribute("class", "globe-ring")
const track = document.createElementNS(ns, "circle")
track.setAttribute("class", "globe-ring-track")
const value = document.createElementNS(ns, "circle")
value.setAttribute("class", "globe-ring-value")
svg.append(track, value)
overlay.appendChild(svg)
return { svg, track, value, radius: 0, cx: 0, cy: 0, length: 0 }
}
function overlap(a, b) {
return a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]
}
+11
View File
@@ -27,3 +27,14 @@ 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.
**Before upgrading globe.gl**, check what the app relies on inside the bundle, since it never sees `window.THREE`:
- **`wwwroot/js/instruments.js`** takes three.js's classes from objects globe.gl made (`classesOf`):
- the globe mesh, whose material is `globeMaterial()`, inside a `Group`;
- the graticules, a `LineSegments` with a `LineBasicMaterial`, added hidden;
- an empty `Mesh`, for `BufferGeometry` and `MeshBasicMaterial`;
- the camera's `Vector3`.
It also relies on the scene's `onBeforeRender` and `onAfterRender` running on every frame. If any of these change,
the instruments fall back to the ring and the legend, and the e2e `GlobeTests` fail on `built`.
- **`wwwroot/js/globe.js`** relies on the texture class of the day map and on the globe material's shader chunks.