From e94e32d1988458aaad6d2e776705efe033736890 Mon Sep 17 00:00:00 2001 From: thepra Date: Wed, 7 Oct 2026 02:15:06 +0200 Subject: [PATCH] The globe's nerd stats are instruments in the scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 35 +- Components/Feed/GlobePanel.razor | 186 ++---- Resources/AllStrings.it.resx | 69 ++- Resources/AllStrings.resx | 69 ++- Styles/feed.css | 131 +++++ tests/e2e/Render/GlobeTests.cs | 159 +++++ wwwroot/js/globe.js | 70 ++- wwwroot/js/instruments.js | 967 +++++++++++++++++++++++++++++++ wwwroot/vendor/globe/SOURCES.md | 11 + 9 files changed, 1516 insertions(+), 181 deletions(-) create mode 100644 tests/e2e/Render/GlobeTests.cs create mode 100644 wwwroot/js/instruments.js diff --git a/CLAUDE.md b/CLAUDE.md index c4b6d93..f34cd10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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. +- **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` 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`. @@ -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 Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every 30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card, - `data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment, the phase and the season - in the panel's top corner. The theme changes only the atmosphere and the points. + `data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment and its phase at the panel's + 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 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 @@ -409,8 +437,7 @@ supplies: - **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last - request; the globe's camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance, - the place's elevation and day length, the next equinox or solstice). + request. The globe's are its instruments (see **The globe**), with no tooltip and no poll. **Localization (`Services/CoalescingStringLocalizer.cs`).** - Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then diff --git a/Components/Feed/GlobePanel.razor b/Components/Feed/GlobePanel.razor index afabf9f..7199d73 100644 --- a/Components/Feed/GlobePanel.razor +++ b/Components/Feed/GlobePanel.razor @@ -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 view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post - was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when - the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the - Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The - globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. Below lg the - globe is a banner the page shows 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. *@ + was written. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place, + the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at + 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 + module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is + polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows + 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. *@
- + + @* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@ +
+ @if (sun is not null) + { + @foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value))) + { +
@row.Label
+
@row.Value
+ } + } +
+

- @if (stats?.Sun is { } sun) + @* the moment the Sun is shown at: now, or the focused post's *@ + @if (sun is not null) { -
- -
- - - @(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At) - - @if (sun.Phase is { } phase) - { - @sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime] - @Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"] - } - else - { - @Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]] - } -
-
-
+

+ + @(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At) +

+ } + else if (error is { Length: > 0 }) + { +

@Localizer["The globe could not start."]

} @if (Expanded) @@ -50,25 +55,6 @@ } -
- - - - @if (stats is { Error: null } live) - { - @Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps") - } - else if (stats?.Error is { Length: > 0 }) - { - @Localizer["The globe could not start."] - } - else - { - @Localizer["loading"]… - } - - -
@code { @@ -90,7 +76,20 @@ bool focusClose; 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() { @@ -109,12 +108,13 @@ ElementReference element; IJSObjectReference module; DotNetObjectReference reference; - PeriodicTimer statsTimer; readonly CancellationTokenSource cancellation = new(); 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; - GlobeStats stats; + SunStats sun; + string error; + readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8]; protected override void OnInitialized() => IsLoading = false; @@ -150,10 +150,11 @@ { module = await JS.InvokeAsync("import", "./js/globe.js"); reference = DotNetObjectReference.Create(this); - await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, - new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName }); - statsTimer = new(TimeSpan.FromSeconds(1)); - _ = ReadStats(cancellation.Token); + await module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new + { + Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, + Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key]) + }); } if (postsHandedOver) { @@ -213,7 +214,8 @@ : null; } - /// The globe's Sun moved to another moment (now, or a post's): the overlay follows at once. + /// 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). [JSInvokable] public async Task SunChanged() { @@ -222,7 +224,7 @@ try { - stats = await module.InvokeAsync("stats", cancellation.Token); + sun = await module.InvokeAsync("sunStats", cancellation.Token); StateHasChanged(); } catch (OperationCanceledException) @@ -233,42 +235,12 @@ } } - async Task ReadStats(CancellationToken cancellationToken) + /// The globe could not start (no WebGL, its library failed to load). + [JSInvokable] + public void GlobeFailed(string message) { - try - { - while (await statsTimer.WaitForNextTickAsync(cancellationToken)) - { - stats = await module.InvokeAsync("stats", cancellationToken); - await InvokeAsync(StateHasChanged); - } - } - catch (OperationCanceledException) - { - } - catch (JSDisconnectedException) - { - } - } - - IEnumerable 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); + error = message; + StateHasChanged(); } IEnumerable SunRows(SunStats sun) @@ -340,7 +312,6 @@ overlay?.Dispose(); await cancellation.CancelAsync(); cancellation.Dispose(); - statsTimer?.Dispose(); reference?.Dispose(); try { @@ -361,37 +332,6 @@ 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; } - } - /// The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post. sealed class SunStats { diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 2f0decd..b1505f1 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -569,9 +569,6 @@ dispersione - - texture - il server dell'autore @@ -629,15 +626,6 @@ {0}: {1} - - sotto il puntatore - - - archi - - - anelli - collocati @@ -683,12 +671,6 @@ fermo - - fermo: nulla si muove, nulla viene disegnato - - - Sole - Ora @@ -710,9 +692,6 @@ paesi - - con nome a questo zoom - confini @@ -1871,4 +1850,52 @@ Immagine {0} di {1} + + linea giorno/notte + + + mezzogiorno solare + + + mezzanotte solare + + + Tropico del Cancro + + + Tropico del Capricorno + + + Circolo polare artico + + + Circolo polare antartico + + + a quest'ora, tutto l'anno + + + il Sole non tramonta + + + il Sole non sorge + + + perielio + + + afelio + + + non in scala + + + mappa del giorno + + + mappe del giorno e della notte + + + anisotropia {0}× + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 34e2589..6cd2f7e 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -569,9 +569,6 @@ spread - - texture - the author's server @@ -629,15 +626,6 @@ {0}: {1} - - hovered - - - arcs - - - rings - placed @@ -683,12 +671,6 @@ idle - - idle: nothing moves, nothing is drawn - - - Sun - Now @@ -710,9 +692,6 @@ countries - - named at this zoom - borders @@ -1871,4 +1850,52 @@ Picture {0} of {1} + + 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 + diff --git a/Styles/feed.css b/Styles/feed.css index aee137a..b0d320a 100644 --- a/Styles/feed.css +++ b/Styles/feed.css @@ -130,4 +130,135 @@ outline: 2px solid var(--color-accent); 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); + } } diff --git a/tests/e2e/Render/GlobeTests.cs b/tests/e2e/Render/GlobeTests.cs new file mode 100644 index 0000000..2df4914 --- /dev/null +++ b/tests/e2e/Render/GlobeTests.cs @@ -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 Stats(IPage page) => + await page.EvaluateAsync("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); + } + } +} diff --git a/wwwroot/js/globe.js b/wwwroot/js/globe.js index 534d59d..21e4522 100644 --- a/wwwroot/js/globe.js +++ b/wwwroot/js/globe.js @@ -16,6 +16,7 @@ // 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 { createInstruments } from "./instruments.js" const vendor = new URL("../vendor/globe/", import.meta.url).href 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, reference, names: regionNames(options?.language), + language: options?.language, + labels: options?.labels ?? {}, + instruments: null, scroller: document.getElementById(scrollContainerId), sun: now, sunPoint: { lat: now.lat, lng: now.lng }, @@ -137,12 +141,18 @@ export function stats() { countries: state.countries.length, countryNames: state.countryNames, 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. -function sunStats() { +// The Sun's moment as the panel's caption and its text for screen readers show it: now, or the focused post's moment +// 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 focus = state.focus 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)) if (state.globe) { state.globe.pauseAnimation() + state.instruments?.dispose() state.globe._destructor?.() state.element.replaceChildren() } @@ -198,6 +209,7 @@ function resize() { return } state.globe.width(width).height(height) + state.instruments?.setSize(width, height) pause(document.visibilityState !== "visible") wake() // 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) .globeImageUrl(textures.day) 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() loadCountries() const controls = globe.controls() @@ -277,7 +295,8 @@ async function build(width, height) { wake(1500) }) 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(() => { if (!state.focus) aimSun(sunAt(new Date())) @@ -288,10 +307,12 @@ async function build(width, height) { drawPoints() scheduleView() pause(document.visibilityState !== "visible") + state.reference?.invokeMethodAsync("SunChanged").catch(() => { }) } catch (e) { state.error = e.message console.error(e) window.logFromJs?.(e.message, "globe.build") + state.reference?.invokeMethodAsync("GlobeFailed", e.message).catch(() => { }) } finally { if (state === current) state.building = false @@ -384,6 +405,7 @@ function keepSharp() { night.needsUpdate = true state.sunUniforms.nightMap.value = night state.sunUniforms.nightReady.value = 1 + state.instruments?.setFacts({ night: true }) wake(500) } } @@ -405,6 +427,8 @@ function aimSun(sun) { if (reducedMotion.matches) state.sunPoint = { lat: sun.lat, lng: sun.lng } 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 wake(100) state.reference?.invokeMethodAsync("SunChanged").catch(() => { }) @@ -419,12 +443,14 @@ function stepSun() { if (point.lat !== state.sun.lat || point.lng !== state.sun.lng) { state.sunPoint = { lat: state.sun.lat, lng: state.sun.lng } state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) + state.instruments?.setSun(state.sunPoint) } return } const k = Math.abs(dLat) < 0.5 && Math.abs(dLng) < 0.5 ? 1 : 0.12 state.sunPoint = { lat: point.lat + dLat * k, lng: point.lng + dLng * k } state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) + state.instruments?.setSun(state.sunPoint) wake(300) } @@ -472,6 +498,7 @@ async function loadCountries() { }) state.countries = data.countries.map(c => ({ kind: "country", ...c })) state.globe.pathsData(state.borders) + state.instruments?.setFacts({ countries: state.countries.length, borders: state.borders.length }) drawPoints() placeLabels(true) wake(500) @@ -522,7 +549,8 @@ function placeLabels(force = false) { const perDegree = pixelsPerDegree(camera.altitude) const horizon = 1 / (1 + camera.altitude) const view = unit(camera.lat, camera.lng) - const taken = [] + const chips = state.instruments?.boxes() ?? [] + const taken = [...chips] for (const country of state.countries) { const element = state.labelOf.get(country) if (!element) @@ -546,7 +574,8 @@ function placeLabels(force = false) { } 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) { @@ -688,6 +717,7 @@ function applyTheme() { return state.colours = null state.globe.atmosphereColor(colour("server", 1)) + state.instruments?.setColours() drawPoints() wake(2000) } @@ -707,7 +737,12 @@ function colour(kind, alpha = 0.95) { place: read("--color-accent"), country: read("--color-secondary"), 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() return colours @@ -765,7 +800,11 @@ function drawPoints() { if (!state?.globe) return 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]) drawRings() } @@ -814,12 +853,15 @@ function hover(key) { drawRings() if (!post) { state.globe.arcsData([]) + state.instruments?.setArc(null) fly(state.view, 900) return } if (!placed(post)) { const group = state.groups?.find(g => g.key === post.cdn) - state.globe.arcsData(group && state.self ? [{ startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng }] : []) + const 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 if (target && distance(state.globe.pointOfView(), target) > 2500) 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" ? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self) : state.self - state.globe.arcsData(from && distance(from, post) > 30 - ? [{ startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng }] - : []) + const toPost = from && distance(from, post) > 30 ? { startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng, label: post.label } : null + state.globe.arcsData(toPost ? [toPost] : []) + state.instruments?.setArc(toPost) const camera = state.globe.pointOfView() if (distance(camera, post) > 2500) fly({ lat: post.lat, lng: post.lng, altitude: camera.altitude }, 900) @@ -851,6 +893,7 @@ function updateView() { if (located.length === 0) { state.view = null state.spreadKm = 0 + state.instruments?.setView(null, 0, [], { located: 0, visible: state.visible.size }) controls.autoRotate = !reducedMotion.matches wake() return @@ -870,6 +913,7 @@ function updateView() { state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p))) mean.altitude = Math.min(3.6, (1.5 + state.spreadKm / 3500) * narrowness()) state.view = mean + state.instruments?.setView(mean, state.spreadKm, located, { located: located.length, visible: state.visible.size }) if (!state.hovered) fly(mean, 1200) } @@ -900,6 +944,7 @@ function readRenderer() { const gl = state.globe.renderer().getContext() const info = gl.getExtension("WEBGL_debug_renderer_info") state.renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER) + state.instruments?.setFacts({ renderer: state.renderer }) } catch { state.renderer = null } @@ -923,6 +968,7 @@ function countFrames() { state.fps = state.running ? state.frames : 0 state.frames = 0 settle() + state.instruments?.setFps(state.fps, { idle: state.idle, paused: state.paused, autoRotate: !!state.globe?.controls().autoRotate }) }, 1000)) } diff --git a/wwwroot/js/instruments.js b/wwwroot/js/instruments.js new file mode 100644 index 0000000..2f2aa7f --- /dev/null +++ b/wwwroot/js/instruments.js @@ -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] +} diff --git a/wwwroot/vendor/globe/SOURCES.md b/wwwroot/vendor/globe/SOURCES.md index 1d5ab0e..c4774d2 100644 --- a/wwwroot/vendor/globe/SOURCES.md +++ b/wwwroot/vendor/globe/SOURCES.md @@ -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 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.