Compare commits

...
Author SHA1 Message Date
Claude 5ce1320fe8 Put the official site first in the README and in package.json
Moves the official-site line directly under the README title, and points
package.json's homepage at godseyeview.ai instead of the README anchor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011hsCV6Ap9bk7WtxUbbbxnT
2026-11-01 18:24:50 +00:00
Claude 99370c29da Link the official site, godseyeview.ai, from the README
Adds an official-site line under the tagline and in the closing links so the
repository points readers and search engines at godseyeview.ai. Merge after
the site is live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011hsCV6Ap9bk7WtxUbbbxnT
2026-11-01 18:22:03 +00:00
Sameh Khamis 0d1a68160e Merge pull request #1494 from bilawalsidhu/sameh/weather-product-metadata
Let a weather source describe its own products
2026-10-31 23:07:08 -07:00
Sameh Khamis 70494698bc Keep a refreshed weather credit through frame installs and host switches
A refreshed manifest could lose its credit in two ways. A frame still
loading when the refresh arrived had no credit yet, so the refresh was
skipped and the frame installed with the credit of the snapshot it was
staged from. And a host switch restaged the shown frame, then re-applied
the refreshed manifest captured when the restage began, overwriting any
newer refresh that arrived while it loaded.

Each frame now remembers the latest refreshed manifest of its product,
whether or not its imagery is installed yet, and takes its credit from it,
falling back to the snapshot it was staged from. That snapshot still
supplies the time and bounds. A restage passes the newest refreshed
manifest to the new frame up front instead of re-applying a captured one
afterwards, and a refresh that arrives while a frame loads is kept for a
later restage.
2026-10-31 22:58:06 -07:00
Sameh Khamis 903f59302b Add the changelog entry for weather product display metadata 2026-10-31 22:42:45 -07:00
Sameh Khamis 039b0828b7 Let a weather source describe its own products
A weather source can describe its own products (credit, units, coverage,
legend) through an optional `display` object in each product manifest:
source, credit, units, coverage, info, legend and extent. The weather
layer uses each valid field in the row summary, legend, info text,
coverage navigation, outside-coverage status, layer stats and imagery
credit, and keeps today's built-in value for any field that is absent or
invalid. Units and legend describe one scale, so they apply only as a
valid pair; otherwise both keep their built-in values, and the radar
info heading names the units in use.

Text is single-line and length-capped. It rejects control characters
(U+0000-U+001F, U+007F-U+009F), line and paragraph separators, and
invisible or format characters: soft hyphen (U+00AD), combining grapheme
joiner (U+034F), Arabic letter mark (U+061C), Mongolian vowel separator
(U+180E), zero-width and direction marks (U+200B-U+200F), bidi embedding
and override (U+202A-U+202E), word joiner, invisible operators and bidi
isolates (U+2060-U+2069), variation selectors (U+FE00-U+FE0F), the
byte-order mark (U+FEFF) and tag characters (U+E0000-U+E007F).

Legends need 2-16 stops whose labels are plain decimal numbers or short
text and whose colours are #rrggbb. A number label must read back
exactly as the legend shows it: within the safe integer range, with no
leading zeros and no digits lost to rounding. Extents must be finite, in
range and non-empty. Supplied source and units text is data: the layer
marks it so the panels show it untranslated, while built-in values are
translated as before.

The imagery credit is escaped as plain text and follows a refreshed
manifest even when the shown frame stays the same. A refresh changes
only the credit: the shown frame keeps the snapshot it was staged from,
so a later host switch restages it with its own time and bounds and the
refreshed credit. Manifests without `display`, including the bundled
nowCOAST ones, render exactly as before.
2026-10-31 22:42:01 -07:00
Said Turksever 3cd7d0fe15 Merge pull request #1461 from quanticsoul4772/upstream/sky-test-windows-path
fix(sky): read the Milky Way faces by file path in the paint-out test
2026-11-01 01:36:44 +00:00
Said Turksever 544027053d Merge branch 'main' into upstream/sky-test-windows-path 2026-11-01 01:15:25 +00:00
Sameh Khamis 3824a47df8 Merge pull request #1493 from bilawalsidhu/sameh/firms-test-localhost
test(firms): stop the all-addresses connect test depending on how localhost resolves
2026-10-31 17:58:29 -07:00
Said Turksever f52e2e62ee Merge branch 'main' into upstream/sky-test-windows-path 2026-11-01 00:55:14 +00:00
Sameh Khamis 20ad658f6c Resolve localhost to both loopbacks in the real connect-failure test
The FIRMS test that checks a real all-addresses connect failure relied on
the system resolving localhost to both ::1 and 127.0.0.1. On a host whose
localhost resolves only to 127.0.0.1, fetch makes a single attempt, gets a
plain ECONNREFUSED instead of an AggregateError, and the test fails.

Mock dns.lookup for that fetch to return both loopback addresses, so the
real fetch still attempts both families and the error shape check stays
deterministic. Assert the mock was used, so a runtime that stops resolving
through dns.lookup fails loudly instead of silently testing something else.
2026-10-31 17:50:16 -07:00
Said Turksever b4c75749e8 Merge pull request #1446 from Rohilalala/fix/e-draw-area-unit
fix(draw): show a 9,999.6 m² area as 1.0 ha, not 10000 m²
2026-11-01 00:10:52 +00:00
Said Turksever 684dff3b55 Merge branch 'main' into fix/e-draw-area-unit 2026-11-01 00:04:22 +00:00
Sameh Khamis 8978801dfd Merge pull request #1491 from bilawalsidhu/sameh/gnss-source-window
Let a GNSS integrity source set the layer's evidence window
2026-10-31 16:48:51 -07:00
Sameh Khamis 691f27387b Add the changelog entry for the GNSS source window 2026-10-31 16:41:46 -07:00
Said Turksever cb6fd7568a test(draw): pin the shoelace area in the km² and ha rounding cases 2026-10-31 23:18:42 +00:00
Said Turksever 84028f71fe Merge branch 'main' into fix/e-draw-area-unit 2026-10-31 23:18:42 +00:00
Sameh Khamis e1bbbcec5a Let a GNSS integrity source set the layer's evidence window
The catalog now passes the gnss-integrity source's windowMs to the layer.
A missing, non-finite or non-positive value keeps the 30-minute default.
The row text now names the window the layer uses instead of a fixed 30 min.
2026-10-31 14:33:48 -07:00
Said Turksever a725178e0f Merge pull request #1472 from bilawalsidhu/authoring-panel
Rename the Scenes panel to Authoring and lay it out in steps
2026-10-30 17:00:57 +00:00
Said Turksever 3119b2cf9c Merge branch 'main' into authoring-panel 2026-10-30 16:40:50 +00:00
Said Turksever a4820315d9 Translate Authoring for the six new languages and fit long words
Adds the Authoring labels and tooltips to the de, fr, it, ja, pt and uk
catalogs that arrived from main, and prunes the retired SCENES-era
entries there. The file row's tighter padding and letter spacing keep
one long translated word per line in the narrow Cyber panel
(IMPORTIEREN, ESECUZIONE, ІМПОРТУВАТИ, EJECUCIÓN).
2026-10-29 18:47:56 +00:00
Said Turksever 2b05461232 Merge remote-tracking branch 'origin/main' into authoring-panel
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-29 18:40:02 +00:00
Said Turksever 5a80facf8d Address review: playable PLAY/NEXT, visible rename editor, accurate copy
- PLAY and NEXT continue into the following scenes, so they stay
  enabled while any scene in the project has a shot, not only the
  selected one.
- Renaming a shot drops the number and ellipsis so the editor is no
  longer hidden; qa-scene-controls checks a long rename stays visible.
- PLAY, NEXT, EDIT DETAILS and SHARE SCENE tooltips describe what the
  buttons do, translated in every shipped language.
- The primary actions and the file row keep a hover state.
- Docs: the EXPORT rename in DIRECTOR-SHARING and the playback button
  states in CURRENT-STATE are accurate.
- Test the voice "authoring" alias through set_panel_open.
2026-10-29 18:39:53 +00:00
Said Turksever 8de75b172a Keep two shot rows visible in Authoring on shorter windows
The step headings make the panel taller, so the left stack squeezed the
shot list down to one row at about 1000px of window height. Its minimum
now holds two rows; the panel scrolls when space is short.
2026-10-29 12:29:11 +00:00
Said Turksever 4192a6f87d Keep Authoring within its stack and theme, and cover its wiring
- Keep the documented 72px / flex-grow 1 action sizing and the 360px
  panel width; the larger type applies to the Authoring panel only, so
  CCTV and Street Level buttons are unchanged.
- Scope the primary and file-row styles to the panel so Cyber's shared
  button surface no longer overrides them.
- Shorten the scene PLAY in Spanish and Russian so it does not break
  mid-word, and fix two translation inconsistencies.
- Map the spoken name "authoring" to the panel.
- Clear the has-shots marker when the controls are destroyed.
- Test the idle button states through published changes, the PLAY
  translation context, and where the sharing row is placed.
2026-10-29 11:48:52 +00:00
Said Turksever 452292001a Add the changelog fragment for the Authoring panel 2026-10-29 11:32:27 +00:00
Said Turksever 2b1129c10e Rename the Scenes panel to Authoring and lay it out in steps
The panel reads AUTHORING and groups its controls under SCENE, SHOTS,
PREVIEW and SAVE & SHARE headings, with the EDIT DETAILS / SHARE SCENE
row moved beside EXPORT / IMPORT. Text is larger with more contrast,
shots are numbered, every action has a tooltip and the status line is a
live region. Outside playback, buttons that would do nothing are
disabled. New labels are translated in every shipped language; PLAY
uses its own catalog context apart from the radio's PLAY.
2026-10-29 11:31:53 +00:00
quanticsoul4772andClaude Opus 5.5 da2a3c3379 fix(sky): read the Milky Way faces by file path in the paint-out test
The test passed URL.pathname to sharp, which is /D:/... on Windows, so the
faces were not found and npm test failed on every Windows clone.
fileURLToPath gives a path that works on every platform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:08:10 -07:00
Aditya 76a3d47e56 docs(changelog): add #1446 fragment 2026-10-28 16:24:42 +05:30
Aditya d1bbfdbca5 fix(draw): show a 9,999.6 m² area as 1.0 ha, not 10000 m²
The draw hint picked m², ha or km² from the raw area and then rounded,
so 9,999.5 m² up to a hectare read "10000 m²" and about 999,500 m² up
to a square kilometre read "100.0 ha". It now rounds first, as the line
measure already does.
2026-10-28 15:25:08 +05:30
47 changed files with 2227 additions and 223 deletions

No files matched your search

+3 -1
View File
@@ -2,6 +2,8 @@
# 🌐 God's Eye View
**Official site: [godseyeview.ai](https://godseyeview.ai)** · Official source: this repository · Hosted version coming soon
[![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) [![Reached #1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](https://x.com/bilawalsidhu/status/2093798887815348521)
### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real.
@@ -681,7 +683,7 @@ One heads-up from the inside: build in this space for a week and you learn that
<div align="center">
▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project
🌐 [godseyeview.ai](https://godseyeview.ai), the official site · ▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project
**🌐 God's Eye View. No place left behind.**
+3
View File
@@ -0,0 +1,3 @@
- Show a drawn area of 9,999.5 m² or more as 1.0 ha, and of 999,500 m² or
more as 1.00 km². The draw hint picked the unit before rounding, so it read
"10000 m²" and "100.0 ha" (Aditya, @Rohilalala, #1446).
+4
View File
@@ -0,0 +1,4 @@
- Rename the Scenes panel to Authoring and lay it out in steps (Scene, Shots,
Preview, Save & share) with larger, higher-contrast text, numbered shots, a
tooltip on every action and buttons that are disabled when they would do
nothing (asturksever, #1472).
+3
View File
@@ -0,0 +1,3 @@
- Let a GNSS integrity source set the layer's evidence window (`windowMs`). A
missing or invalid value keeps the 30-minute default, and the layer row now
names the window in use instead of a fixed "30 min" (samehkhamis, #1491).
@@ -0,0 +1,4 @@
- Let a weather source describe its own products through an optional
`display` object in each manifest (source, credit, units, coverage, info,
legend and extent). Each valid field replaces the built-in value, and
manifests without it render as before (samehkhamis, #1494).
+52 -11
View File
@@ -475,6 +475,33 @@ not worldwide detections; units are strikes/km²/min ×10³. It is neither raw G
flashes nor a live strike counter. Weather imagery orders scalar context below
satellite, radar and lightning consistently across enable and history order.
A weather source can describe its own products (credit, units, coverage,
legend) through an optional `display` object in each product manifest:
`source` (≤60 characters, the row's source name), `credit` (≤200, the imagery
credit in Data attribution, shown as plain text and updated when a refreshed
manifest changes it), `units` (≤40), `coverage` (≤48, the coverage badge and
info line), `info` (≤600, the info tooltip), `legend` (2–16 `{ label, color }`
stops; label a plain decimal number or text, at most 16 characters either way,
a number reading back exactly as the legend shows it: within ±2^53−1, with
no leading zeros and no digits lost to rounding; color `#rrggbb`) and `extent`
(`{ west, south, east, north }` degrees, non-empty; west greater than east
crosses the antimeridian; used for coverage navigation and the
outside-coverage status). `units` and `legend` describe one scale and
apply only together: if either is absent or invalid, both keep the built-in
values, and the radar info heading names the units in use. Text must be
single-line, without control characters (U+0000–U+001F, U+007F–U+009F), line
or paragraph separators, or invisible and format characters: soft hyphen
(U+00AD), combining grapheme joiner (U+034F), Arabic letter mark (U+061C),
Mongolian vowel separator (U+180E), zero-width and direction marks
(U+200B–U+200F), bidi embedding and override (U+202A–U+202E), word joiner,
invisible operators and bidi isolates (U+2060–U+2069), variation selectors
(U+FE00–U+FE0F), byte-order mark (U+FEFF) and tag characters
(U+E0000–U+E007F). Each other field is validated on its own; an absent or
invalid field keeps the built-in value, so the bundled nowCOAST manifests,
which carry no `display`, look exactly as before. Accepted source
and units text is source data and is shown untranslated. The static Data
attribution entries remain the application's `credits` list.
Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded
same-origin provider. The status endpoint lacks browser CORS; each installation
fetches keyless public sources at runtime, with no centralized ingestion service.
@@ -544,7 +571,7 @@ Native `<select>` controls declare a dark colour scheme and explicit option
colours, so the browser-painted option list matches the panel it drops out of.
The scheme is declared on the controls rather than on the document root, so
hand-tuned scrollbar and text-input skins are unchanged. Applies to the HUD
layout, Scenes, CCTV camera, Radio filter and Draw colour menus.
layout, Authoring, CCTV camera, Radio filter and Draw colour menus.
Vessel snapshot completeness is separate from freshness. A current snapshot with
rejected or duplicate records shows PARTIAL with accepted/received counts; stale
@@ -615,7 +642,7 @@ from revealing its UI. Ambiguous requests to open settings ask whether the user
means Voice Settings or Provider Settings before opening either panel. Named
Voice Settings applies directly; Provider Settings opens the existing local
provider dialog when available. Visual Presets targets the visual controls tray,
while Scenes targets saved scenes. Selecting Preview does not enable a disabled
while Scenes (the Authoring panel) targets saved scenes. Selecting Preview does not enable a disabled
Preview preference. Unverified speech captions use the localized Recent
speech label; ordinary user captions retain YOU. Caption attribution does not
control tool dispatch.
@@ -748,7 +775,7 @@ Nepal scene ownership follows the parameters prepared by the public layer manage
Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation.
Nepal Flood Incident is accessed through Scenes. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched.
Nepal Flood Incident is accessed through Authoring. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched.
Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project.
@@ -762,7 +789,7 @@ Persistent media-led Nepal shots preserve their completed upstream flood path du
Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior.
Nepal Flood Incident is available in Scenes, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
Nepal Flood Incident is available in Authoring, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved.
@@ -1190,6 +1217,19 @@ Shot selection updates the highlight without replacing the row, preserving
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
late file and failed-action completions cannot update removed presentation.
The panel is titled Authoring and is laid out in four labelled steps: SCENE
(picker on its own row, NEW SCENE, DELETE), SHOTS (+ CAPTURE SHOT, UPDATE SHOT,
the numbered shot list and a select/rename hint), PREVIEW (PLAY, STOP, NEXT) and
SAVE & SHARE (EXPORT, IMPORT, RUN LOG, then the EDIT DETAILS / SHARE SCENE row).
Every button in the step rows has a tooltip, and the status line is a polite
live region. Outside playback a button that would do nothing is disabled:
DELETE and CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot,
PLAY and NEXT when no scene in the project has a shot (both continue into the
following scenes). During playback the authoring and file actions stay disabled
and STOP is enabled, as before; RUN LOG follows the last run, and EDIT DETAILS /
SHARE SCENE keep their own checks. PLAY uses the `scene|PLAY` catalog entry,
apart from the radio's PLAY.
Director clock listeners, interaction observers and scene release failures no
longer leave the director inconsistent, and a phase error is kept when cleanup
also fails.
@@ -1535,7 +1575,7 @@ collapse can still be undone.
## Phone layout
At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and
shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below),
shows a bottom tab bar: Layers (Data Layers, with Authoring collapsed below),
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
Recent Imagery when shown), Search (Location) and More (Visual Presets and Map
Source above Display). A tab opens one bottom sheet; the same tab, the backdrop
@@ -1566,7 +1606,7 @@ panel the user changed inside the sheet stays as they left it. Escape closes the
sheet before a panel's own Escape handling, and the dock's hover auto-close does
not apply inside a sheet.
Cockpit, recording and clean view close the sheet and hide the tab bar; scene
playback keeps both so its STOP control in the Scenes panel stays reachable. The
playback keeps both so its STOP control in the Authoring panel stays reachable. The
right rail neither hides collapsed siblings nor applies Cyber's one-panel
accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the
voice control, and above an open sheet. The page draws under the notch and
@@ -3012,7 +3052,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> sibling is also removed from the accessibility tree, and focus transfers to
> the expanded utility if a layout transition hides the focused launcher. The
> shared map Display, Radio detail,
> ordinary Global Context, and Scenes surfaces stay hidden in Cockpit. Data
> ordinary Global Context, and Authoring surfaces stay hidden in Cockpit. Data
> Layers remains available. Its Cockpit-only stack paints
> above the curved speed and altitude rulers, matching the existing Context,
> Signals, Display, and Radio surface ordering without changing map-view
@@ -4837,7 +4877,7 @@ silently demoting every later lookup for the session.
- Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct.
- With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip.
- The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence.
- The right rail's collapsed Display, CCTV, and Context controls use the same compact sizing language as the left rail's collapsed Data Layers and Scenes controls. These compact-state widths do not constrain expanded panel or child-content widths.
- The right rail's collapsed Display, CCTV, and Context controls use the same compact sizing language as the left rail's collapsed Data Layers and Authoring controls. These compact-state widths do not constrain expanded panel or child-content widths.
### Motion & Symbology Correctness (June 10, 2026)
@@ -5425,6 +5465,7 @@ request viewbox and continues searching without that geographic preference.
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`.
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
@@ -5610,7 +5651,7 @@ apply to `public/` assets or built-preview serving.
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally.
- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Scenes was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would not remove the vertical scrollbar. Context draws no glow at all (`display: none`), so it is not part of that pin. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet.
- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Authoring was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would not remove the vertical scrollbar. Context draws no glow at all (`display: none`), so it is not part of that pin. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet.
- `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay.
- Collapsed mini-status indicators show active style and active location/landmark.
- Detection mode is user-controlled and should persist when switching styles. Since 2026-08-22 it also STARTS on — Dense @ 75% for every style on a first run, Normal included — as a `GLOBAL_POST_DEFAULTS` baseline that does NOT set `_detectionUserOverridden`. Exception (unchanged): selecting a military style (CRT/NVG/FLIR) auto-enables the same Dense preset, but only until the user manually changes detection this session (`_detectionUserOverridden` gate), after which style switches never touch it.
@@ -5675,8 +5716,8 @@ apply to `public/` assets or built-preview serving.
be enabled, and a civilian or military aircraft must be tracked. The visible
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
flight-layer selection cannot enter a context-dependent cockpit.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Scenes and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
+2 -2
View File
@@ -1,6 +1,6 @@
# Director authoring and file sharing
Open Scenes and select a scene and shot. Existing capture, update, playback and
Open Authoring and select a scene and shot. Existing capture, update, playback and
whole-project export controls remain available. Authored scene content, source
links, assets and contributor credit are preserved.
@@ -42,7 +42,7 @@ field definitions. This is a basic draft editor, not a node graph or path editor
SHARE SCENE exports only the selected scene. Download scene JSON preserves its
camera, timing, layers, anchors, manifests, interactions and attribution, but
contains no asset bytes. The recipient needs the referenced source registrations
and files. Existing EXPORT PRESETS continues to export the entire project.
and files. EXPORT (formerly EXPORT PRESETS) continues to export the entire project.
For a bundle, select the declared pack files or a folder, then Download asset
bundle. Folder paths match a manifest path either directly or after removing the
+1 -1
View File
@@ -1,6 +1,6 @@
# Scene document format
Scenes exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md)
Authoring exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md)
and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on
import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with
a field path before the current project, selection or saved bytes change.
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "module",
"license": "MIT",
"author": "Bilawal Sidhu",
"homepage": "https://github.com/bilawalsidhu/gods-eye-view#readme",
"homepage": "https://godseyeview.ai",
"repository": {
"type": "git",
"url": "https://github.com/bilawalsidhu/gods-eye-view.git"
+1
View File
@@ -897,6 +897,7 @@
"scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs",
"src/layers/weather/imageryHost.test.mjs",
"src/layers/weather/display.test.mjs",
"src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs",
+2
View File
@@ -2641,6 +2641,7 @@
"src/layers/weather/index.js",
"src/layers/weather/clock.js",
"src/layers/weather/rendering.js",
"src/layers/weather/display.js",
"src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js",
@@ -3273,6 +3274,7 @@
"src/layers/weather/index.js",
"src/layers/weather/clock.js",
"src/layers/weather/rendering.js",
"src/layers/weather/display.js",
"src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js",
+33
View File
@@ -106,6 +106,39 @@ try {
),
),
);
// A long rename keeps its editor visible inside the shot row.
await page.click('.scene-shot-label', { count: 2 });
await page.waitForSelector('.scene-shot-rename', { visible: true });
await page.type(
'.scene-shot-rename',
' with a much longer title that runs well past the shot row',
);
await page.keyboard.press('End');
check(
'A long rename keeps the editor visible inside its shot row',
await page.evaluate(() => {
// The label clips with an ellipsis, which hides an editor that
// overflows it, so its content must fit its width.
const input = document.querySelector('.scene-shot-rename');
const label = input?.closest('.scene-shot-label');
const box = input?.getBoundingClientRect();
const style = input && getComputedStyle(input);
return (
!!box &&
box.width > 120 &&
box.height > 10 &&
label.scrollWidth <= label.clientWidth + 1 &&
style.visibility === 'visible' &&
style.borderTopWidth !== '0px' &&
document.activeElement === input
);
}),
);
await page.keyboard.press('Escape');
await page.waitForFunction(
() => !document.querySelector('.scene-shot-rename'),
);
await page.click('#scene-capture-btn');
await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 2,
+6 -3
View File
@@ -215,9 +215,12 @@ export function formatMeasure(session) {
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
return `${Math.round(m2)} m²`;
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
const whole = Math.round(m2);
const ha = (m2 / 1e4).toFixed(1);
if (whole < 1e4) return `${whole} m²`;
if (Number(ha) < 100) return `${ha} ha`;
return `${(m2 / 1e6).toFixed(2)} km²`;
}
return '';
}
+40
View File
@@ -345,3 +345,43 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
test('an area that rounds up to the next unit reads in that unit', () => {
// A square on the equator whose shoelace area is about `m2`.
const square = (m2) => {
const side = Math.sqrt(m2) / 111320;
const area = createDrawSession('area');
for (const [lon, lat] of [
[0, 0],
[side, 0],
[side, side],
[0, side],
])
addVertex(area, { lon, lat });
return area;
};
const almostHectare = square(9999.6);
const metres = ringAreaM2(almostHectare.vertices);
assert.ok(
metres >= 9999.5 && metres < 1e4,
`area should round to 10000 m², got ${metres}`,
);
assert.equal(formatMeasure(almostHectare), '1.0 ha');
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
const almostKm2 = square(999_960);
const nearKm2 = ringAreaM2(almostKm2.vertices);
assert.ok(
nearKm2 >= 999_500 && nearKm2 < 1e6,
`area should round to 100.0 ha, got ${nearKm2}`,
);
assert.equal(formatMeasure(almostKm2), '1.00 km²');
const under = square(999_400);
const underM2 = ringAreaM2(under.vertices);
assert.ok(
underM2 >= 998_500 && underM2 < 999_500,
`area should round to 99.9 ha, got ${underM2}`,
);
assert.equal(formatMeasure(under), '99.9 ha');
});
+1
View File
@@ -126,6 +126,7 @@ export function createApplicationCatalog({
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }),
+39
View File
@@ -193,3 +193,42 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
/Mapillary source/,
);
});
test('a GNSS integrity source can set the layer evidence window', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const gnssLayer = (descriptor) => {
const sources = fixtureSources([], []);
sources['gnss-integrity'] = {
getSnapshot: async () => ({}),
...descriptor,
};
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}).get('gnss-integrity');
};
const shows = (layer, minutes) => {
assert.equal(layer.provenance.window.minutes, minutes);
const { info, legend } = layer.getRowControls();
assert.match(info, new RegExp(`visited views over ${minutes} min`));
assert.match(legend[0].blurb, new RegExp(`over ${minutes} minutes`));
};
shows(gnssLayer({ windowMs: 65 * 60_000 }), 65);
shows(gnssLayer({}), 30);
for (const windowMs of [
undefined,
null,
NaN,
Infinity,
0,
-60_000,
'3600000',
])
assert.equal(
gnssLayer({ windowMs }).provenance.window.minutes,
30,
`${String(windowMs)} falls back to the default`,
);
});
+6 -1
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import dns from 'node:dns/promises';
import dnsLookup from 'node:dns';
import https from 'node:https';
import net from 'node:net';
import { Readable } from 'node:stream';
@@ -258,7 +259,8 @@ test('the IPv4 retry answering an invalid reason phrase rejects instead of crash
// The fixtures above copy Node's error shape; check it against a real fetch so
// a Node or undici change that stops matching fails here instead of silently
// disabling the retry. A closed port on `localhost` refuses both addresses.
// disabling the retry. A closed port refuses both loopback addresses; resolve
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
const probe = net.createServer();
try {
@@ -272,7 +274,10 @@ test('a real all-addresses connect failure is recognised as address selection',
const v4 = net.createServer();
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
await new Promise((resolve) => v4.close(resolve));
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
assert.ok(err, 'a closed port refuses the connection');
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
+27 -8
View File
@@ -837,13 +837,7 @@
"SAVE CAL": "KAL. SPEICHERN",
"RESET CAL": "KAL. ZURÜCKSETZEN",
"SCENE SUMMARY": "SZENEN-ÜBERSICHT",
"SCENES": "SZENEN",
"Scene recipe": "Szenenrezept",
"NEW": "NEU",
"CAPTURE SHOT": "AUFNAHME SPEICHERN",
"UPDATE SHOT": "AUFNAHME AKTUALISIEREN",
"START": "START",
"EXPORT PRESETS": "PRESETS EXPORTIEREN",
"IMPORT": "IMPORTIEREN",
"RUN LOG": "PROTOKOLL",
"POWER UP": "STARTKLAR",
@@ -1464,7 +1458,7 @@
"Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %",
"Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %",
"GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über {minutes} min",
"Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC",
"Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.",
@@ -1765,6 +1759,8 @@
"Contiguous US · gaps ≠ no rain": "USA ohne Alaska/Hawaii · Lücken ≠ kein Regen",
"Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze",
"Color: strikes/km²/min ×10³": "Farbe: Blitze/km²/min ×10³",
"Color: {units}": "Farbe: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert",
"North America · infrared imagery": "Nordamerika · Infrarotbilder",
"Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar",
@@ -2926,5 +2922,28 @@
"Malformed cyclone snapshot": "Fehlerhafter Wirbelsturm-Snapshot",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache",
"routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig",
"route proxy error": "Fehler des Routen-Proxys"
"route proxy error": "Fehler des Routen-Proxys",
"scene|PLAY": "START",
"AUTHORING": "ERSTELLUNG",
"Create and edit scenes": "Szenen erstellen und bearbeiten",
"SCENE": "SZENE",
"Scene": "Szene",
"NEW SCENE": "NEUE SZENE",
"Start a new, empty scene": "Neue, leere Szene beginnen",
"DELETE": "LÖSCHEN",
"Delete this scene and all of its shots": "Diese Szene mit allen Aufnahmen löschen",
"SHOTS": "AUFNAHMEN",
"+ CAPTURE SHOT": "+ AUFNAHME SPEICHERN",
"Add the current view, style and layers as the next shot": "Aktuelle Ansicht, Stil und Ebenen als nächste Aufnahme hinzufügen",
"Replace the selected shot with the current view": "Ausgewählte Aufnahme durch die aktuelle Ansicht ersetzen",
"Click a shot to select it, double-click to rename.": "Aufnahme anklicken zum Auswählen, Doppelklick zum Umbenennen.",
"Play from this scene through the rest of the project": "Ab dieser Szene bis zum Ende des Projekts abspielen",
"Stop playback (Esc)": "Wiedergabe stoppen (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Aufnahme nach der ausgewählten abspielen, bei Bedarf in der nächsten Szene",
"SAVE & SHARE": "SPEICHERN & TEILEN",
"Download every scene as a JSON file you can import later": "Alle Szenen als JSON-Datei zum späteren Import herunterladen",
"Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden",
"Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen",
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen"
}
+27 -8
View File
@@ -752,14 +752,8 @@
"The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "El globo ya funciona sin claves. Cada clave de abajo activa otra fuente real — pega una y queda guardada en la configuración local de esta app, y luego el servidor se reinicia solo. Las claves del servidor se quedan en esta máquina; Google Maps y Cesium ion funcionan en el navegador y sus claves deben restringirse en la consola del proveedor. Las claves que configuraste en otro lugar se muestran, pero nunca se modifican.",
"ESC to close": "ESC para cerrar",
"The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps activa el planeta fotorrealista; todo lo demás se superpone.",
"SCENES": "ESCENAS",
"Scene recipe": "Receta de escena",
"NEW": "NUEVA",
"DEL": "BORRAR",
"CAPTURE SHOT": "CAPTURAR PLANO",
"UPDATE SHOT": "ACTUALIZAR PLANO",
"START": "INICIAR",
"EXPORT PRESETS": "EXPORTAR PREAJUSTES",
"IMPORT": "IMPORTAR",
"RUN LOG": "REGISTRO DE EJECUCIÓN",
"Starting live contacts…": "Iniciando contactos en vivo…",
@@ -1266,6 +1260,8 @@
"Contiguous US · gaps ≠ no rain": "EE. UU. contiguos · huecos ≠ sin lluvia",
"Americas + Pacific · not individual strikes": "América + Pacífico · no son rayos individuales",
"Color: strikes/km²/min ×10³": "Color: rayos/km²/min ×10³",
"Color: {units}": "Color: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso",
"North America · infrared imagery": "Norteamérica · imágenes infrarrojas",
"Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible",
@@ -2164,7 +2160,7 @@
"Low-accuracy score 2–10%": "Índice de baja precisión 2–10%",
"Low-accuracy score > 10%": "Índice de baja precisión > 10%",
"GNSS integrity source unavailable": "Fuente de integridad GNSS no disponible",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en {minutes} min",
"Retrying automatically at {time} UTC": "Reintento automático a las {time} UTC",
"Retrying automatically after provider cooldown": "Reintento automático tras la pausa del proveedor",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar o hacer zoom y una vez por minuto. El feed público cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no lo convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.",
@@ -2996,5 +2992,28 @@
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas"
"route proxy error": "error del proxy de rutas",
"AUTHORING": "CREACIÓN",
"Create and edit scenes": "Crea y edita escenas",
"SCENE": "ESCENA",
"Scene": "Escena",
"NEW SCENE": "NUEVA ESCENA",
"Start a new, empty scene": "Empieza una escena nueva y vacía",
"DELETE": "ELIMINAR",
"Delete this scene and all of its shots": "Elimina esta escena y todos sus planos",
"SHOTS": "PLANOS",
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
"Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano",
"Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual",
"Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.",
"scene|PLAY": "REPROD.",
"Stop playback (Esc)": "Detén la reproducción (Esc)",
"SAVE & SHARE": "GUARDAR Y COMPARTIR",
"Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde",
"Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado",
"Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción",
"Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto",
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta",
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos"
}
+27 -8
View File
@@ -771,7 +771,7 @@
"Low-accuracy score 2–10%": "Indice de faible précision 2–10 %",
"Low-accuracy score > 10%": "Indice de faible précision > 10 %",
"GNSS integrity source unavailable": "Source d'intégrité GNSS indisponible",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Échantillon régional · rayon de 250 NM · cellules des vues visitées sur 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Échantillon régional · rayon de 250 NM · cellules des vues visitées sur {minutes} min",
"Retrying automatically at {time} UTC": "Nouvel essai automatique à {time} UTC",
"Retrying automatically after provider cooldown": "Nouvel essai automatique après la pause du fournisseur",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Mise à jour automatique après un déplacement ou un zoom, et une fois par minute. Le flux public couvre un cercle de 250 milles nautiques autour du centre de la vue ; dézoomer n'en fait pas un instantané mondial. Les cellules vides sont hors de la couverture échantillonnée ou manquent d'observations.",
@@ -1214,6 +1214,8 @@
"Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³",
"Color: {units}": "Couleur : {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
"Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible",
@@ -2959,13 +2961,7 @@
"ALL": "TOUTES",
"FLAT": "CLASSIQUES",
"SINCE": "DEPUIS",
"SCENES": "SCÈNES",
"Scene recipe": "Recette de scène",
"NEW": "NOUV.",
"CAPTURE SHOT": "CAPTURER UN PLAN",
"UPDATE SHOT": "MAJ DU PLAN",
"START": "LANCER",
"EXPORT PRESETS": "EXPORTER LES PRÉRÉGLAGES",
"IMPORT": "IMPORTER",
"RUN LOG": "JOURNAL",
"POWER UP": "ALLUMER",
@@ -2996,5 +2992,28 @@
"ESC to dismiss": "ESC pour fermer",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique"
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
"scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes",
"SCENE": "SCÈNE",
"Scene": "Scène",
"NEW SCENE": "NOUVELLE SCÈNE",
"Start a new, empty scene": "Commencer une nouvelle scène vide",
"DELETE": "SUPPRIMER",
"Delete this scene and all of its shots": "Supprimer cette scène et tous ses plans",
"SHOTS": "PLANS",
"+ CAPTURE SHOT": "+ CAPTURER UN PLAN",
"Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant",
"Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle",
"Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.",
"Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet",
"Stop playback (Esc)": "Arrêter la lecture (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin",
"SAVE & SHARE": "ENREGISTRER ET PARTAGER",
"Download every scene as a JSON file you can import later": "Télécharger toutes les scènes dans un fichier JSON à importer plus tard",
"Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté",
"Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture",
"Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans",
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources"
}
+27 -8
View File
@@ -771,7 +771,7 @@
"Low-accuracy score 2–10%": "Indice di bassa precisione 2–10%",
"Low-accuracy score > 10%": "Indice di bassa precisione > 10%",
"GNSS integrity source unavailable": "Fonte di integrità GNSS non disponibile",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Campione regionale · raggio di 250 NM · celle dalle viste visitate negli ultimi 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Campione regionale · raggio di 250 NM · celle dalle viste visitate negli ultimi {minutes} min",
"Retrying automatically at {time} UTC": "Nuovo tentativo automatico alle {time} UTC",
"Retrying automatically after provider cooldown": "Nuovo tentativo automatico dopo la pausa del provider",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Si aggiorna automaticamente dopo uno spostamento o uno zoom e una volta al minuto. Il feed pubblico copre un cerchio di 250 miglia nautiche attorno al centro della vista; allontanare lo zoom non lo rende un'istantanea mondiale. Le celle vuote sono fuori dalla copertura campionata o hanno osservazioni insufficienti.",
@@ -1214,6 +1214,8 @@
"Contiguous US · gaps ≠ no rain": "USA contigui · lacune ≠ assenza di pioggia",
"Americas + Pacific · not individual strikes": "Americhe + Pacifico · non singoli fulmini",
"Color: strikes/km²/min ×10³": "Colore: fulmini/km²/min ×10³",
"Color: {units}": "Colore: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo",
"North America · infrared imagery": "Nord America · immagini all'infrarosso",
"Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile",
@@ -2959,13 +2961,7 @@
"ALL": "TUTTE",
"FLAT": "PIATTE",
"SINCE": "DAL",
"SCENES": "SCENE",
"Scene recipe": "Ricetta della scena",
"NEW": "NUOVA",
"CAPTURE SHOT": "CATTURA SCATTO",
"UPDATE SHOT": "AGGIORNA SCATTO",
"START": "AVVIA",
"EXPORT PRESETS": "ESPORTA PRESET",
"IMPORT": "IMPORTA",
"RUN LOG": "LOG ESECUZIONE",
"POWER UP": "ACCENDI",
@@ -2996,5 +2992,28 @@
"ESC to dismiss": "ESC per chiudere",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout"
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
"scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene",
"SCENE": "SCENA",
"Scene": "Scena",
"NEW SCENE": "NUOVA SCENA",
"Start a new, empty scene": "Inizia una nuova scena vuota",
"DELETE": "ELIMINA",
"Delete this scene and all of its shots": "Elimina questa scena e tutti i suoi scatti",
"SHOTS": "SCATTI",
"+ CAPTURE SHOT": "+ CATTURA SCATTO",
"Add the current view, style and layers as the next shot": "Aggiungi la vista, lo stile e i livelli attuali come scatto successivo",
"Replace the selected shot with the current view": "Sostituisci lo scatto selezionato con la vista attuale",
"Click a shot to select it, double-click to rename.": "Fai clic su uno scatto per selezionarlo, doppio clic per rinominarlo.",
"Play from this scene through the rest of the project": "Riproduci da questa scena fino alla fine del progetto",
"Stop playback (Esc)": "Interrompi la riproduzione (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Riproduci lo scatto dopo quello selezionato, passando alla scena successiva se serve",
"SAVE & SHARE": "SALVA E CONDIVIDI",
"Download every scene as a JSON file you can import later": "Scarica tutte le scene in un file JSON da importare in seguito",
"Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato",
"Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione",
"Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti",
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse"
}
+27 -8
View File
@@ -749,7 +749,7 @@
"Low-accuracy score 2–10%": "低精度スコア 2–10%",
"Low-accuracy score > 10%": "低精度スコア > 10%",
"GNSS integrity source unavailable": "GNSSインテグリティのソースを利用できません",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "地域サンプル · 半径250海里 · 30分以内に表示したビューのセル",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · {minutes}分以内に表示したビューのセル",
"Retrying automatically at {time} UTC": "{time} UTCに自動で再試行します",
"Retrying automatically after provider cooldown": "プロバイダーのクールダウン後に自動で再試行します",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "パンやズームの後、および1分ごとに自動で更新されます。公開フィードは表示中心から半径250海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。",
@@ -1182,6 +1182,8 @@
"Contiguous US · gaps ≠ no rain": "米国本土 · 欠測 ≠ 降水なし",
"Americas + Pacific · not individual strikes": "南北アメリカ + 太平洋 · 個々の落雷ではありません",
"Color: strikes/km²/min ×10³": "色:落雷数/km²/分 ×10³",
"Color: {units}": "色:{units}",
"RADAR · {units}": "レーダー · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ",
"North America · infrared imagery": "北米 · 赤外画像",
"Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可",
@@ -2817,13 +2819,7 @@
"ALL": "すべて",
"FLAT": "平面",
"SINCE": "期間",
"SCENES": "シーン",
"Scene recipe": "シーンレシピ",
"NEW": "新規",
"CAPTURE SHOT": "ショット取得",
"UPDATE SHOT": "ショット更新",
"START": "開始",
"EXPORT PRESETS": "エクスポート",
"IMPORT": "インポート",
"RUN LOG": "実行ログ",
"POWER UP": "パワーアップ",
@@ -2856,5 +2852,28 @@
"routing busy — too many routes at once": "ルート検索サービスが混雑しています。同時に要求されたルートが多すぎます",
"route proxy error": "ルート検索プロキシでエラーが発生しました",
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました"
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
"scene|PLAY": "再生",
"AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集",
"SCENE": "シーン",
"Scene": "シーン",
"NEW SCENE": "新規シーン",
"Start a new, empty scene": "空の新しいシーンを作成",
"DELETE": "削除",
"Delete this scene and all of its shots": "このシーンとすべてのショットを削除",
"SHOTS": "ショット",
"+ CAPTURE SHOT": "+ ショット取得",
"Add the current view, style and layers as the next shot": "現在の視点・スタイル・レイヤーを次のショットとして追加",
"Replace the selected shot with the current view": "選択中のショットを現在の視点で置き換え",
"Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。",
"Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生",
"Stop playback (Esc)": "再生を停止(Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)",
"SAVE & SHARE": "保存と共有",
"Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード",
"Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む",
"Download the timing log of the last play": "前回の再生のタイミングログをダウンロード",
"Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集",
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード"
}
+27 -8
View File
@@ -769,7 +769,7 @@
"Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%",
"Low-accuracy score > 10%": "Índice de baixa precisão > 10%",
"GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Amostra regional · raio de 250 NM · células das vistas visitadas em 30 min",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das vistas visitadas em {minutes} min",
"Retrying automatically at {time} UTC": "Nova tentativa automática às {time} UTC",
"Retrying automatically after provider cooldown": "Nova tentativa automática após a pausa do provedor",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Atualiza automaticamente após mover ou dar zoom e uma vez por minuto. O feed público cobre um círculo de 250 milhas náuticas ao redor do centro da vista; afastar o zoom não o transforma em um snapshot mundial. Células vazias estão fora da cobertura amostrada ou não têm observações suficientes.",
@@ -1212,6 +1212,8 @@
"Contiguous US · gaps ≠ no rain": "EUA contíguos · lacunas ≠ sem chuva",
"Americas + Pacific · not individual strikes": "Américas + Pacífico · não são raios individuais",
"Color: strikes/km²/min ×10³": "Cor: raios/km²/min ×10³",
"Color: {units}": "Cor: {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso",
"North America · infrared imagery": "América do Norte · imagens infravermelhas",
"Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível",
@@ -2961,13 +2963,7 @@
"ALL": "TODAS",
"FLAT": "PLANAS",
"SINCE": "DESDE",
"SCENES": "CENAS",
"Scene recipe": "Receita de cena",
"NEW": "NOVA",
"CAPTURE SHOT": "CAPTURAR PLANO",
"UPDATE SHOT": "ATUALIZAR PLANO",
"START": "INICIAR",
"EXPORT PRESETS": "EXPORTAR PREDEF.",
"IMPORT": "IMPORTAR",
"RUN LOG": "REGISTRO",
"POWER UP": "ATIVAR",
@@ -2996,5 +2992,28 @@
"Begin with a clean globe": "Comece com um globo limpo",
"Don't show this again": "Não mostrar novamente",
"ESC to dismiss": "ESC para fechar",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa."
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.",
"scene|PLAY": "INICIAR",
"AUTHORING": "CRIAÇÃO",
"Create and edit scenes": "Criar e editar cenas",
"SCENE": "CENA",
"Scene": "Cena",
"NEW SCENE": "NOVA CENA",
"Start a new, empty scene": "Começar uma nova cena vazia",
"DELETE": "EXCLUIR",
"Delete this scene and all of its shots": "Excluir esta cena e todos os seus planos",
"SHOTS": "PLANOS",
"+ CAPTURE SHOT": "+ CAPTURAR PLANO",
"Add the current view, style and layers as the next shot": "Adicionar a vista, o estilo e as camadas atuais como próximo plano",
"Replace the selected shot with the current view": "Substituir o plano selecionado pela vista atual",
"Click a shot to select it, double-click to rename.": "Clique em um plano para selecioná-lo e clique duas vezes para renomeá-lo.",
"Play from this scene through the rest of the project": "Reproduzir desta cena até o fim do projeto",
"Stop playback (Esc)": "Parar a reprodução (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Reproduzir o plano após o selecionado, passando à próxima cena se necessário",
"SAVE & SHARE": "SALVAR E COMPARTILHAR",
"Download every scene as a JSON file you can import later": "Baixar todas as cenas em um arquivo JSON para importar depois",
"Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado",
"Download the timing log of the last play": "Baixar o registro de tempos da última reprodução",
"Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos",
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos"
}
+27 -8
View File
@@ -795,21 +795,15 @@
"East offset from catalog position (m) — click to type": "Смещение на восток от позиции каталога (м) — нажмите для ввода",
"SAVE CAL": "СОХРАНИТЬ КАЛИБР.",
"RESET CAL": "СБРОСИТЬ КАЛИБР.",
"NEW": "НОВАЯ",
"DEL": "УДАЛ.",
"CAPTURE SHOT": "СДЕЛАТЬ КАДР",
"UPDATE SHOT": "ОБНОВИТЬ КАДР",
"START": "СТАРТ",
"EXPORT PRESETS": "ЭКСПОРТ ПРЕСЕТОВ",
"IMPORT": "ИМПОРТ",
"RUN LOG": "ЖУРНАЛ ЗАПУСКОВ",
"CALIBRATION": "КАЛИБРОВКА",
"Camera pose — click a value to type": "Поза камеры — щёлкните по значению, чтобы ввести",
"SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ",
"CCTV camera": "Камера CCTV",
"Scene recipe": "Рецепт сцены",
"DATA LAYERS": "СЛОИ ДАННЫХ",
"SCENES": "СЦЕНЫ",
"Close key setup": "Закрыть настройку ключей",
"SAVE KEYS": "СОХРАНИТЬ КЛЮЧИ",
"Power up the globe": "Подключите потоки данных",
@@ -1284,6 +1278,8 @@
"Contiguous US · gaps ≠ no rain": "Континентальные США · пробелы ≠ нет дождя",
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не отдельные разряды",
"Color: strikes/km²/min ×10³": "Цвет: разрядов/км²/мин ×10³",
"Color: {units}": "Цвет: {units}",
"RADAR · {units}": "РАДАР · {units}",
"60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч",
"North America · infrared imagery": "Северная Америка · ИК-снимки",
"Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно",
@@ -2198,7 +2194,7 @@
"Low-accuracy score 2–10%": "Доля неточных 2–10%",
"Low-accuracy score > 10%": "Доля неточных > 10%",
"GNSS integrity source unavailable": "Источник целостности GNSS недоступен",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Региональная выборка · радиус 250 мор. миль · ячейки из просмотренных видов за 30 мин",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Региональная выборка · радиус 250 мор. миль · ячейки из просмотренных видов за {minutes} мин",
"Retrying automatically at {time} UTC": "Автоматический повтор в {time} UTC",
"Retrying automatically after provider cooldown": "Автоматический повтор после паузы провайдера",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Обновляется автоматически после сдвига или масштабирования карты и раз в минуту. Публичный поток данных охватывает круг радиусом 250 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.",
@@ -3066,5 +3062,28 @@
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов"
"route proxy error": "ошибка прокси маршрутов",
"AUTHORING": "РЕДАКТОР",
"Create and edit scenes": "Создание и редактирование сцен",
"SCENE": "СЦЕНА",
"Scene": "Сцена",
"NEW SCENE": "НОВАЯ СЦЕНА",
"Start a new, empty scene": "Создать новую пустую сцену",
"DELETE": "УДАЛИТЬ",
"Delete this scene and all of its shots": "Удалить эту сцену и все её кадры",
"SHOTS": "КАДРЫ",
"+ CAPTURE SHOT": "+ СДЕЛАТЬ КАДР",
"Add the current view, style and layers as the next shot": "Добавить текущий вид, стиль и слои следующим кадром",
"Replace the selected shot with the current view": "Заменить выбранный кадр текущим видом",
"Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.",
"scene|PLAY": "ПУСК",
"Stop playback (Esc)": "Остановить воспроизведение (Esc)",
"SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ",
"Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта",
"Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON",
"Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения",
"Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту",
"Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене",
"Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров",
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов"
}
+27 -8
View File
@@ -781,7 +781,7 @@
"Low-accuracy score 2–10%": "Показник неточності 2–10%",
"Low-accuracy score > 10%": "Показник неточності > 10%",
"GNSS integrity source unavailable": "Джерело даних про цілісність GNSS недоступне",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "Регіональна вибірка · радіус 250 мор. миль · комірки з переглянутих ділянок карти за 30 хв",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Регіональна вибірка · радіус 250 мор. миль · комірки з переглянутих ділянок карти за {minutes} хв",
"Retrying automatically at {time} UTC": "Автоматичний повтор о {time} UTC",
"Retrying automatically after provider cooldown": "Автоматичний повтор після паузи постачальника",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Оновлюється автоматично після зсуву чи масштабування карти та раз на хвилину. Публічний потік даних охоплює коло радіусом 250 морських миль навколо центру карти; у разі віддалення карта не стає знімком усього світу. Порожні комірки — поза охопленням вибірки або з недостатньою кількістю спостережень.",
@@ -1229,6 +1229,8 @@
"Contiguous US · gaps ≠ no rain": "48 суміжних штатів США · прогалини ≠ немає дощу",
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди",
"Color: strikes/km²/min ×10³": "Колір: розрядів/км²/хв ×10³",
"Color: {units}": "Колір: {units}",
"RADAR · {units}": "РАДАР · {units}",
"60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год",
"North America · infrared imagery": "Північна Америка · ІЧ-знімки",
"Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне",
@@ -3029,13 +3031,7 @@
"ALL": "УСІ",
"FLAT": "ПЛОСКІ",
"SINCE": "З ДАТИ",
"SCENES": "СЦЕНИ",
"Scene recipe": "Рецепт сцени",
"NEW": "НОВА",
"CAPTURE SHOT": "ЗНЯТИ КАДР",
"UPDATE SHOT": "ОНОВИТИ КАДР",
"START": "СТАРТ",
"EXPORT PRESETS": "ЕКСПОРТУВАТИ ПРЕСЕТИ",
"IMPORT": "ІМПОРТУВАТИ",
"RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ",
"POWER UP": "ЗАПУСК",
@@ -3066,5 +3062,28 @@
"ESC to dismiss": "ESC — приховати",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів"
"Cyclone request timed out": "Минув час очікування запиту циклонів",
"scene|PLAY": "СТАРТ",
"AUTHORING": "РЕДАКТОР",
"Create and edit scenes": "Створення й редагування сцен",
"SCENE": "СЦЕНА",
"Scene": "Сцена",
"NEW SCENE": "НОВА СЦЕНА",
"Start a new, empty scene": "Створити нову порожню сцену",
"DELETE": "ВИДАЛИТИ",
"Delete this scene and all of its shots": "Видалити цю сцену з усіма кадрами",
"SHOTS": "КАДРИ",
"+ CAPTURE SHOT": "+ ЗНЯТИ КАДР",
"Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр",
"Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом",
"Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.",
"Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту",
"Stop playback (Esc)": "Зупинити відтворення (Esc)",
"Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени",
"SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ",
"Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту",
"Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON",
"Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення",
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів"
}
+27 -8
View File
@@ -726,7 +726,7 @@
"Low-accuracy score 2–10%": "低精度评分 2–10%",
"Low-accuracy score > 10%": "低精度评分 > 10%",
"GNSS integrity source unavailable": "GNSS 完好性数据源不可用",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "区域采样 · 半径 250 海里 · 30 分钟内已访问视图中的网格",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内已访问视图中的网格",
"Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试",
"Retrying automatically after provider cooldown": "服务商冷却结束后自动重试",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视野中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。",
@@ -1156,6 +1156,8 @@
"Contiguous US · gaps ≠ no rain": "美国本土 · 数据缺失 ≠ 无降雨",
"Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击",
"Color: strikes/km²/min ×10³": "颜色:闪击次数/km²/分钟 ×10³",
"Color: {units}": "颜色:{units}",
"RADAR · {units}": "雷达 · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时",
"North America · infrared imagery": "北美洲 · 红外影像",
"Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用",
@@ -2712,13 +2714,7 @@
"ALL": "全部",
"FLAT": "平面",
"SINCE": "起始时间",
"SCENES": "场景",
"Scene recipe": "场景方案",
"NEW": "新建",
"CAPTURE SHOT": "保存镜头",
"UPDATE SHOT": "更新镜头",
"START": "启动",
"EXPORT PRESETS": "导出预设",
"IMPORT": "导入",
"RUN LOG": "运行日志",
"POWER UP": "增强",
@@ -2856,5 +2852,28 @@
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
"route proxy error": "路线代理出错"
"route proxy error": "路线代理出错",
"AUTHORING": "创作",
"Create and edit scenes": "创建和编辑场景",
"SCENE": "场景",
"Scene": "场景",
"NEW SCENE": "新建场景",
"Start a new, empty scene": "新建一个空场景",
"DELETE": "删除",
"Delete this scene and all of its shots": "删除此场景及其所有镜头",
"SHOTS": "镜头",
"+ CAPTURE SHOT": "+ 保存镜头",
"Add the current view, style and layers as the next shot": "将当前视角、样式和图层添加为下一个镜头",
"Replace the selected shot with the current view": "用当前视角替换所选镜头",
"Click a shot to select it, double-click to rename.": "单击镜头即可选中,双击可重命名。",
"scene|PLAY": "播放",
"Stop playback (Esc)": "停止播放(Esc)",
"SAVE & SHARE": "保存与分享",
"Download every scene as a JSON file you can import later": "将所有场景下载为 JSON 文件,以便日后导入",
"Load scenes from an exported JSON file": "从导出的 JSON 文件加载场景",
"Download the timing log of the last play": "下载上次播放的计时日志",
"Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景",
"Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情",
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包"
}
+27 -8
View File
@@ -721,7 +721,7 @@
"Low-accuracy score 2–10%": "低精度評分 2–10%",
"Low-accuracy score > 10%": "低精度評分 > 10%",
"GNSS integrity source unavailable": "GNSS 完好性數據源不可用",
"Regional sample · 250 nm radius · cells from visited views over 30 min": "區域採樣 · 半徑 250 海里 · 30 分鐘內已訪問視圖中的網格",
"Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內已訪問視圖中的網格",
"Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試",
"Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試",
"Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。",
@@ -1151,6 +1151,8 @@
"Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨",
"Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊",
"Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³",
"Color: {units}": "顏色:{units}",
"RADAR · {units}": "雷達 · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時",
"North America · infrared imagery": "北美洲 · 紅外影像",
"Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用",
@@ -2700,13 +2702,7 @@
"ALL": "全部",
"FLAT": "平面",
"SINCE": "起始時間",
"SCENES": "場景",
"Scene recipe": "場景方案",
"NEW": "新建",
"CAPTURE SHOT": "保存鏡頭",
"UPDATE SHOT": "更新鏡頭",
"START": "啟動",
"EXPORT PRESETS": "導出預設",
"IMPORT": "導入",
"RUN LOG": "運行日誌",
"POWER UP": "增強",
@@ -2856,5 +2852,28 @@
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯"
"route proxy error": "路線代理出錯",
"AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景",
"SCENE": "場景",
"Scene": "場景",
"NEW SCENE": "新建場景",
"Start a new, empty scene": "新建一個空場景",
"DELETE": "刪除",
"Delete this scene and all of its shots": "刪除此場景及其所有鏡頭",
"SHOTS": "鏡頭",
"+ CAPTURE SHOT": "+ 保存鏡頭",
"Add the current view, style and layers as the next shot": "將當前視角、樣式和圖層添加為下一個鏡頭",
"Replace the selected shot with the current view": "用當前視角替換所選鏡頭",
"Click a shot to select it, double-click to rename.": "單擊鏡頭即可選中,雙擊可重命名。",
"scene|PLAY": "播放",
"Stop playback (Esc)": "停止播放(Esc)",
"SAVE & SHARE": "保存與分享",
"Download every scene as a JSON file you can import later": "將所有場景下載為 JSON 文件,以便日後導入",
"Load scenes from an exported JSON file": "從導出的 JSON 文件加載場景",
"Download the timing log of the last play": "下載上次播放的計時日誌",
"Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景",
"Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景",
"Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情",
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包"
}
+6
View File
@@ -93,6 +93,7 @@
"Plan",
"Pop",
"Punk",
"RADAR · {units}",
"RADIO",
"REC",
"REGION",
@@ -184,6 +185,7 @@
"CCTV",
"CRS {value}°",
"CRT",
"Color: {units}",
"Country",
"DEST ---°",
"DEST {bearing}",
@@ -235,6 +237,7 @@
"Pop",
"Punk",
"R&B",
"RADAR · {units}",
"RADIO",
"RANGE",
"REC",
@@ -400,6 +403,7 @@
"Plan",
"Pop",
"Punk",
"RADAR · {units}",
"RADIO",
"REC",
"Radio",
@@ -562,6 +566,7 @@
"PRECIP",
"Pop",
"Punk",
"RADAR · {units}",
"RADIO",
"REC",
"RESET",
@@ -765,6 +770,7 @@
"Paris",
"Pop",
"Punk",
"RADAR · {units}",
"RANGE",
"REC",
"Reggae",
+9 -2
View File
@@ -70,10 +70,16 @@ export function createGnssIntegrityLayer({
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS,
windowMs: requestedWindowMs,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source');
// A source may set its own evidence window; anything but a positive finite
// duration keeps the default.
const windowMs =
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
? requestedWindowMs
: GNSS_WINDOW_MS;
let _viewer = null;
let _primitives = null;
let _request = null;
@@ -375,7 +381,8 @@ export function createGnssIntegrityLayer({
chips: [],
info:
t(
'Regional sample · 250 nm radius · cells from visited views over 30 min',
'Regional sample · 250 nm radius · cells from visited views over {minutes} min',
{ minutes: formatNumber(provenance.window.minutes) },
) +
(_lastError && _retryAt
? ` · ${translateFeedError(_lastError)} · ${
+153
View File
@@ -0,0 +1,153 @@
/**
* A weather source can describe its own products (credit, units, coverage,
* legend) in the optional `display` object of a product manifest. Each field
* is checked on its own: an absent or invalid field is dropped, and the layer
* keeps its built-in text for it. Units and legend describe the same scale,
* so they are kept only together. Accepted text is source data, shown as is
* rather than translated.
*/
const TEXT_LIMITS = Object.freeze({
source: 60,
credit: 200,
units: 40,
coverage: 48,
info: 600,
});
const LEGEND_MIN = 2;
const LEGEND_MAX = 16;
const LEGEND_LABEL_MAX = 16;
// Control and line-break characters, plus invisible or format characters:
// soft hyphen, combining grapheme joiner, Arabic letter mark, Mongolian vowel
// separator, zero-width, direction-mark, bidi embedding/override/isolate,
// variation selector, byte-order and tag characters.
const CONTROL =
/[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufe00-\ufe0f\ufeff\u{e0000}-\u{e007f}]/u;
// A number label must read as a plain decimal, as the legend formats it.
const DECIMAL = /^-?\d+(?:\.\d+)?$/;
// A label the legend formats as a number: a plain decimal, maybe with "+".
const SCALE_NUMBER = /^-?(\d+(?:\.(\d+))?)\+?$/;
const COLOR = /^#[0-9a-f]{6}$/i;
const EMPTY = Object.freeze({});
const cache = new WeakMap();
function text(value, max) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed && trimmed.length <= max && !CONTROL.test(trimmed)
? trimmed
: undefined;
}
/**
* Whether a label the legend formats as a number reads back exactly as given:
* within the safe integer range and with no digits lost to rounding, so
* '9999999999999999' (shown as 10000000000000000) and '007' are refused.
* Other text is shown as given.
*/
function exactScaleLabel(label) {
const match = SCALE_NUMBER.exec(label);
if (!match) return true;
const magnitude = Number(match[1]);
return (
magnitude <= Number.MAX_SAFE_INTEGER &&
magnitude.toFixed(match[2]?.length ?? 0) === match[1]
);
}
function number(value) {
if (!Number.isFinite(value)) return undefined;
const label = String(value);
return DECIMAL.test(label) && label.length <= LEGEND_LABEL_MAX
? label
: undefined;
}
function legend(value) {
if (
!Array.isArray(value) ||
value.length < LEGEND_MIN ||
value.length > LEGEND_MAX
)
return undefined;
const stops = [];
for (const stop of value) {
if (!stop || typeof stop !== 'object') return undefined;
const label =
typeof stop.label === 'number'
? number(stop.label)
: text(stop.label, LEGEND_LABEL_MAX);
if (label === undefined || !exactScaleLabel(label)) return undefined;
if (typeof stop.color !== 'string' || !COLOR.test(stop.color))
return undefined;
stops.push(Object.freeze([label, stop.color]));
}
return Object.freeze(stops);
}
function extent(value) {
if (!value || typeof value !== 'object') return undefined;
const { west, south, east, north } = value;
if (
![west, south, east, north].every(Number.isFinite) ||
west < -180 ||
west > 180 ||
east < -180 ||
east > 180 ||
south < -90 ||
north > 90 ||
south >= north ||
// No width: one meridian, including 180 to -180 across the antimeridian.
west === east ||
west - east >= 360
)
return undefined;
// West greater than east crosses the antimeridian.
return Object.freeze({ west, south, east, north });
}
/**
* The valid display fields of a weather manifest; `{}` when it has none.
* @param {object|null|undefined} manifest A validated weather snapshot.
* @returns {{ source?: string, credit?: string, units?: string,
* coverage?: string, info?: string,
* legend?: ReadonlyArray<readonly [string, string]>,
* extent?: { west: number, south: number, east: number, north: number } }}
*/
export function weatherDisplay(manifest) {
const display = manifest?.display;
if (!display || typeof display !== 'object' || Array.isArray(display))
return EMPTY;
const known = cache.get(manifest);
if (known) return known;
const result = {};
for (const [key, max] of Object.entries(TEXT_LIMITS)) {
const value = text(display[key], max);
if (value !== undefined) result[key] = value;
}
const stops = legend(display.legend);
if (stops && result.units !== undefined) result.legend = stops;
else delete result.units;
const box = extent(display.extent);
if (box) result.extent = box;
Object.freeze(result);
cache.set(manifest, result);
return result;
}
/** Whether a longitude/latitude falls inside an extent, across the antimeridian too. */
export function insideExtent({ west, south, east, north }, lon, lat) {
if (lat < south || lat > north) return false;
return west < east ? lon >= west && lon <= east : lon >= west || lon <= east;
}
/** Plain text for a Cesium credit, which otherwise reads its text as HTML. */
export function escapeCreditText(value) {
return value.replace(
/[&<>"']/g,
(ch) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
ch
],
);
}
+939
View File
@@ -0,0 +1,939 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { GeographicTilingScheme } from 'cesium';
import { createWeatherLayer } from './index.js';
import { createWeatherRendering } from './rendering.js';
import { weatherDisplay, insideExtent, escapeCreditText } from './display.js';
import { createShellCesium, createShellScene } from './shellFixture.mjs';
import { formatScaleLabel } from '../../ui/unitLabels.js';
const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z'];
const base = {
schemaVersion: 1,
times,
latest: times[1],
bounds: { west: -180, south: -60, east: 180, north: 80 },
};
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
const quiet = () => ({
matches: false,
hidden: false,
addEventListener() {},
removeEventListener() {},
});
/**
* A layer whose renderer shows every requested frame at once. `center` is the
* map centre as [lon, lat]; `manifests` maps a product to its manifest, and
* may be changed between updates.
*/
async function shownLayer(id, manifest, params, view = {}) {
let time = null;
const flights = [];
const snapshots = [];
const manifests = { [manifest.product]: manifest };
const layer = createWeatherLayer({
id,
feed: { getSnapshot: async ({ product }) => manifests[product] },
documentRef: quiet(),
eventTarget: quiet(),
matchMedia: () => quiet(),
createRendering: () => ({
rehome() {},
setAlpha() {},
setHidden() {},
updateSnapshot(snapshot) {
snapshots.push(snapshot);
},
async setFrame(_snapshot, selected) {
time = selected;
return true;
},
clear() {
time = null;
},
getDiagnostics: () => ({ time, loading: false, error: null }),
}),
});
layer.init({
camera: {
moveEnd: { addEventListener: () => () => {} },
flyTo: ({ destination }) => flights.push(destination),
pickEllipsoid: () =>
view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined,
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
});
if (params) layer.setParams(params);
layer.enable();
await layer.update();
await flush();
layer.attachShellServices({ runNavigation: (navigate) => navigate() });
return { layer, flights, manifests, snapshots };
}
const PRODUCTS = [
['weather-radar', 'radar', undefined],
['weather-lightning', 'lightning', undefined],
['weather-satellite', 'clouds-regional', undefined],
['weather-satellite', 'clouds', { product: 'clouds' }],
];
const INVALID = {
source: 42,
credit: '',
units: 'x'.repeat(41),
coverage: 'line one\nline two',
info: ['not', 'text'],
legend: [
{ label: '1', color: '#ffffff' },
{ label: '2', color: 'red' },
],
extent: { west: 0, south: 10, east: 20, north: 5 },
};
const SATELLITE_TITLE =
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.';
const SATELLITE_CREDIT =
'NOAA nowCOAST · NESDIS GOES / global satellite partners';
// Today's built-in description of each product, written out rather than
// derived from the code under test.
const BUILT_IN = {
radar: {
units: 'dBZ',
coverage: 'CONUS',
legend: [
['10', '#00ecec'],
['20', '#0000f6'],
['30', '#00c800'],
['40', '#ffff00'],
['50', '#ff9000'],
['60', '#dc0000'],
['70', '#ff00ff'],
['80', '#05ede0'],
].map(([label, color]) => ({
label,
color,
blurb: `${label} dBZ radar reflectivity`,
})),
heading: 'RADAR REFLECTIVITY · dBZ',
infoCoverage: 'Contiguous US · gaps ≠ no rain',
infoTitle:
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
chip: 'View US radar',
focus: [-180, -60, 180, 80],
credit: 'NOAA nowCOAST · NWS/OAR MRMS',
},
lightning: {
units: 'strikes/km²/min ×10³',
coverage: 'Americas + Pacific',
legend: [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
['5', '#FF4500'],
['10', '#FF0000'],
['50', '#FF00FF'],
['100', '#4000C0'],
['200', '#00C7FF'],
['300+', '#00FF00'],
].map(([label, color]) => ({
label,
color,
blurb: `${label} strikes/km²/min ×10³ (15-minute density)`,
})),
heading: 'LIGHTNING DENSITY · 15 min accumulation',
infoCoverage:
'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³',
infoTitle:
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
chip: 'View Americas & Pacific',
focus: [110, -25, 0, 80],
credit: 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360',
},
'clouds-regional': {
units: '',
coverage: 'North America',
legend: [],
heading: 'GOES INFRARED · ~5 min',
infoCoverage: 'North America · infrared imagery',
infoTitle: SATELLITE_TITLE,
chip: 'View coverage',
focus: [-180, -60, 180, 80],
credit: SATELLITE_CREDIT,
},
clouds: {
units: '',
coverage: 'Global · 60°S–60°N',
legend: [],
heading: 'GLOBAL INFRARED · hourly',
infoCoverage: '60°S–60°N · typically 2–3 h delayed',
infoTitle: SATELLITE_TITLE,
chip: 'View coverage',
focus: [-180, -60, 180, 80],
credit: SATELLITE_CREDIT,
},
};
/** Assert the built-in description of a product, field by field. */
function assertBuiltIn(layer, product) {
const expected = BUILT_IN[product];
const controls = layer.getRowControls();
assert.equal(controls.summary.units, expected.units);
assert.equal(controls.summary.unitsVerbatim, undefined);
assert.equal(controls.summary.coverage, expected.coverage);
assert.deepEqual(controls.legend, expected.legend);
assert.ok(
controls.info.startsWith(`${expected.heading}\n`),
`${product} heading: ${controls.info}`,
);
assert.ok(
controls.info.endsWith(`\n${expected.infoCoverage}`),
`${product} coverage: ${controls.info}`,
);
assert.equal(controls.infoTitle, expected.infoTitle);
assert.equal(
controls.chips.find(({ id }) => id === 'coverage').label,
expected.chip,
);
const stats = layer.getStats();
assert.equal(stats.source, 'NOAA nowCOAST');
assert.equal(stats.sourceVerbatim, undefined);
}
for (const [id, product, params] of PRODUCTS) {
test(`${product}: a manifest without display metadata keeps today's description`, async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer, flights } = await shownLayer(
id,
{ ...base, product },
params,
);
assertBuiltIn(layer, product);
layer.setParams({ focus: true });
assert.deepEqual(
flights[0],
Cesium.Rectangle.fromDegrees(...BUILT_IN[product].focus),
);
layer.destroy();
});
}
for (const [id, product, params] of PRODUCTS) {
test(`${product}: empty or wholly invalid display metadata changes nothing`, async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const plain = await shownLayer(id, { ...base, product }, params);
const empty = await shownLayer(
id,
{ ...base, product, display: {} },
params,
);
const invalid = await shownLayer(
id,
{ ...base, product, display: INVALID },
params,
);
const expected = plain.layer.getRowControls();
assert.deepEqual(empty.layer.getRowControls(), expected);
assert.deepEqual(invalid.layer.getRowControls(), expected);
assertBuiltIn(invalid.layer, product);
for (const h of [plain, invalid]) h.layer.setParams({ focus: true });
assert.deepEqual(invalid.flights, plain.flights);
for (const h of [plain, empty, invalid]) h.layer.destroy();
});
}
const EXAMPLE = {
source: 'Example network',
credit: 'Example network · flash density',
units: 'flashes/km²/min',
coverage: '52°S–52°N',
info: 'Flash density from an example network. Not a safety warning.',
legend: [
{ label: 0.5, color: '#112233' },
{ label: '2', color: '#445566' },
{ label: '8+', color: '#AABBCC' },
],
extent: { west: -180, south: -52, east: 180, north: 52 },
};
test('valid display metadata describes the product in the row, legend and stats', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer, flights } = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: EXAMPLE,
});
const controls = layer.getRowControls();
assert.equal(controls.summary.units, 'flashes/km²/min');
assert.equal(controls.summary.unitsVerbatim, true);
assert.equal(controls.summary.coverage, '52°S–52°N');
assert.deepEqual(controls.legend, [
{ label: '0.5', color: '#112233', blurb: '0.5 flashes/km²/min' },
{ label: '2', color: '#445566', blurb: '2 flashes/km²/min' },
{ label: '8+', color: '#AABBCC', blurb: '8+ flashes/km²/min' },
]);
assert.match(controls.info, /^LIGHTNING DENSITY · 15 min accumulation\n/);
assert.match(controls.info, /\n52°S–52°N\nColor: flashes\/km²\/min$/);
assert.equal(controls.infoTitle, EXAMPLE.info);
assert.equal(
controls.chips.find(({ id }) => id === 'coverage').label,
'View coverage',
);
const stats = layer.getStats();
assert.equal(stats.source, 'Example network');
assert.equal(stats.sourceVerbatim, true);
layer.setParams({ focus: true });
assert.deepEqual(
flights[0],
Cesium.Rectangle.fromDegrees(-180, -52, 180, 52),
);
layer.destroy();
});
test('units and legend apply only as a valid pair', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const legend = [
{ label: 1, color: '#000000' },
{ label: 10, color: '#ffffff' },
];
for (const [id, product] of [
['weather-lightning', 'lightning'],
['weather-radar', 'radar'],
])
for (const display of [
{ units: 'mm/h' },
{ legend },
{ units: 'mm/h', legend: [legend[0]] },
{ units: 'x'.repeat(41), legend },
]) {
const { layer } = await shownLayer(id, { ...base, product, display });
assertBuiltIn(layer, product);
layer.destroy();
}
const radar = await shownLayer('weather-radar', {
...base,
product: 'radar',
display: { coverage: 'Europe', units: 'mm/h', legend },
});
const controls = radar.layer.getRowControls();
assert.equal(controls.summary.coverage, 'Europe');
assert.equal(controls.summary.units, 'mm/h');
assert.equal(controls.summary.unitsVerbatim, true);
assert.deepEqual(controls.legend, [
{ label: '1', color: '#000000', blurb: '1 mm/h' },
{ label: '10', color: '#ffffff', blurb: '10 mm/h' },
]);
assert.match(controls.info, /^RADAR · mm\/h\n/);
assert.match(controls.info, /\nEurope$/);
radar.layer.destroy();
const clouds = await shownLayer('weather-satellite', {
...base,
product: 'clouds-regional',
display: {
units: 'K',
legend: [
{ label: 'warm', color: '#000000' },
{ label: 'cold', color: '#ffffff' },
],
},
});
assert.equal(clouds.layer.getRowControls().summary.units, 'K');
assert.deepEqual(clouds.layer.getRowControls().legend, [
{ label: 'warm', color: '#000000', blurb: 'warm K' },
{ label: 'cold', color: '#ffffff', blurb: 'cold K' },
]);
clouds.layer.destroy();
});
test('other display fields fall back on their own', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer } = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: { ...INVALID, coverage: 'Example coverage', source: 'Example' },
});
const controls = layer.getRowControls();
assert.equal(controls.summary.coverage, 'Example coverage');
assert.equal(controls.summary.units, BUILT_IN.lightning.units);
assert.deepEqual(controls.legend, BUILT_IN.lightning.legend);
assert.equal(controls.infoTitle, BUILT_IN.lightning.infoTitle);
assert.equal(layer.getStats().source, 'Example');
layer.destroy();
});
test('the map centre is checked against the source extent when one is given', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const OUTSIDE = 'Map center is outside source coverage';
const outside = (layer) => layer.getRowControls().info.includes(OUTSIDE);
const at = async (id, product, display, center) => {
const h = await shownLayer(
id,
{ ...base, product, ...(display ? { display } : {}) },
undefined,
{ center },
);
const result = outside(h.layer);
if (result)
assert.equal(
h.layer.getRowControls().summary.status,
'Map center outside coverage',
);
h.layer.destroy();
return result;
};
// Without an extent: the manifest bounds, plus lightning's built-in gap.
assert.equal(
await at('weather-lightning', 'lightning', null, [50, 10]),
true,
);
assert.equal(
await at('weather-lightning', 'lightning', null, [-100, 10]),
false,
);
assert.equal(await at('weather-radar', 'radar', null, [50, 10]), false);
assert.equal(await at('weather-radar', 'radar', null, [50, -70]), true);
// An extent replaces the built-in gap.
const global = { extent: { west: -180, south: -52, east: 180, north: 52 } };
assert.equal(
await at('weather-lightning', 'lightning', global, [50, 10]),
false,
);
assert.equal(
await at('weather-lightning', 'lightning', global, [50, 60]),
true,
);
const europe = { extent: { west: -10, south: 35, east: 30, north: 70 } };
assert.equal(await at('weather-radar', 'radar', europe, [10, 50]), false);
assert.equal(await at('weather-radar', 'radar', europe, [-50, 50]), true);
assert.equal(await at('weather-radar', 'radar', europe, [10, 30]), true);
// West greater than east crosses the antimeridian.
const pacific = { extent: { west: 150, south: -30, east: -120, north: 60 } };
for (const [center, expected] of [
[[170, 0], false],
[[-150, 0], false],
[[0, 0], true],
[[-100, 0], true],
[[170, 70], true],
])
assert.equal(
await at('weather-radar', 'radar', pacific, center),
expected,
`centre ${center}`,
);
});
test('switching to a product without display metadata restores its built-ins', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const h = await shownLayer('weather-satellite', {
...base,
product: 'clouds-regional',
display: EXAMPLE,
});
h.manifests.clouds = { ...base, product: 'clouds' };
assert.equal(h.layer.getStats().source, 'Example network');
h.layer.setParams({ product: 'clouds' });
await flush();
assertBuiltIn(h.layer, 'clouds');
h.layer.setParams({ product: 'clouds-regional' });
await flush();
assert.equal(h.layer.getStats().source, 'Example network');
assert.equal(h.layer.getRowControls().summary.coverage, '52°S–52°N');
h.layer.destroy();
});
test('a refresh that drops display metadata restores the built-ins', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const h = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: EXAMPLE,
});
assert.equal(h.layer.getStats().source, 'Example network');
const plain = { ...base, product: 'lightning' };
h.manifests.lightning = plain;
await h.layer.update();
assertBuiltIn(h.layer, 'lightning');
assert.equal(h.snapshots.at(-1), plain, 'the renderer sees the refresh');
h.layer.destroy();
});
test('display validation bounds text, legend shape, colours and extents', () => {
const read = (display) => weatherDisplay({ display });
const scale = (legend) => read({ units: 'u', legend }).legend;
assert.deepEqual(read(undefined), {});
assert.deepEqual(read(['credit']), {});
assert.deepEqual(read({ coverage: ' Europe ' }), { coverage: 'Europe' });
assert.deepEqual(read({ coverage: ' ' }), {});
assert.deepEqual(read({ credit: 'a
b' }), {});
assert.deepEqual(read({ credit: 'x'.repeat(200) }).credit.length, 200);
assert.deepEqual(read({ credit: 'x'.repeat(201) }), {});
assert.deepEqual(read({ info: 'x'.repeat(601) }), {});
// Invisible and direction-changing characters are rejected in every field.
for (const code of [
0x00ad, 0x034f, 0x061c, 0x180e, 0x200b, 0x200d, 0x200e, 0x200f, 0x202a,
0x202d, 0x202e, 0x2060, 0x2066, 0x2069, 0xfe00, 0xfe0f, 0xfeff, 0xe0000,
0xe0001, 0xe0041, 0xe007f,
]) {
const ch = String.fromCodePoint(code);
const value = `a${ch}b`;
for (const key of ['source', 'credit', 'units', 'coverage', 'info'])
assert.equal(
read({ [key]: value, legend: [{ label: 1, color: '#000000' }] })[key],
undefined,
`${key} with U+${ch.codePointAt(0).toString(16)}`,
);
assert.equal(
scale([
{ label: '0', color: '#000000' },
{ label: value, color: '#000000' },
]),
undefined,
);
}
assert.equal(read({ coverage: 'a b · c–d' }).coverage, 'a b · c–d');
const stop = (label, color = '#000000') => ({ label, color });
assert.equal(scale([stop('1')]), undefined);
assert.equal(
scale(Array.from({ length: 17 }, (_, i) => stop(String(i)))),
undefined,
);
for (const bad of [
stop(Number.NaN),
stop(Infinity),
stop(0.1 + 0.2),
stop(1e21),
stop(1e-7),
stop(''),
stop('x'.repeat(17)),
stop('1', '#fff'),
stop('1', 'rgb(0,0,0)'),
stop('1', '#00000g'),
null,
'1',
])
assert.equal(scale([stop('0'), bad]), undefined);
// A number label must read back exactly as the legend formats it.
for (const bad of [
'9999999999999999',
'9007199254740992',
'9007199254740992+',
'007',
'00.5',
9007199254740992,
])
assert.equal(scale([stop('0'), stop(bad)]), undefined, String(bad));
assert.deepEqual(
scale([
stop('9007199254740991'),
stop(-900719925474099),
stop('-0.50'),
stop('1.25+'),
stop('123456.789012345'),
]),
[
['9007199254740991', '#000000'],
['-900719925474099', '#000000'],
['-0.50', '#000000'],
['1.25+', '#000000'],
['123456.789012345', '#000000'],
],
);
for (const label of ['9007199254740991', '-0.50', '1.25+', '0.001'])
assert.equal(formatScaleLabel(label), label);
assert.equal(formatScaleLabel('9999999999999999'), '10000000000000000');
assert.deepEqual(scale([stop(0), stop(-2.5), stop('1', '#ABCDEF')]), [
['0', '#000000'],
['-2.5', '#000000'],
['1', '#ABCDEF'],
]);
// A legend needs its units and units need their legend.
assert.deepEqual(read({ units: 'u' }), {});
assert.deepEqual(read({ legend: [stop(0), stop(1)] }), {});
for (const bad of [
{ west: -10, south: 0, east: 10 },
{ west: -10, south: 0, east: 10, north: '5' },
{ west: -181, south: 0, east: 10, north: 5 },
{ west: -10, south: -91, east: 10, north: 5 },
{ west: -10, south: 5, east: 10, north: 5 },
{ west: -10, south: 6, east: 10, north: 5 },
{ west: 10, south: 0, east: 10, north: 5 },
{ west: 180, south: 0, east: -180, north: 5 },
{ west: -180, south: 0, east: -180, north: 5 },
])
assert.equal(read({ extent: bad }).extent, undefined, JSON.stringify(bad));
for (const good of [
{ west: -180, south: -90, east: 180, north: 90 },
{ west: 170, south: 0, east: -180, north: 5 },
])
assert.deepEqual(read({ extent: good }).extent, good);
const wrap = read({ extent: { west: 110, south: -25, east: 0, north: 80 } });
assert.equal(insideExtent(wrap.extent, 150, 10), true);
assert.equal(insideExtent(wrap.extent, -100, 10), true);
assert.equal(insideExtent(wrap.extent, 50, 10), false);
assert.equal(insideExtent(wrap.extent, -100, 81), false);
assert.equal(
escapeCreditText(`<b>"A" & 'B'</b>`),
'&lt;b&gt;&quot;A&quot; &amp; &#39;B&#39;&lt;/b&gt;',
);
});
function mockCanvas() {
const canvas = { width: 0, height: 0 };
canvas.getContext = () => ({
setTransform() {},
drawImage() {},
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
putImageData() {},
});
return canvas;
}
/** A globe viewer whose imagery collection records providers. */
function globeViewer() {
const providers = [];
const layers = [];
const collection = {
addImageryProvider(provider) {
providers.push(provider);
const layer = { imageryProvider: provider, alpha: 1, show: true };
layers.push(layer);
return layer;
},
contains: (layer) => layers.includes(layer),
remove(layer) {
layers.splice(layers.indexOf(layer), 1);
return true;
},
get length() {
return layers.length;
},
get: (index) => layers[index],
indexOf: (layer) => layers.indexOf(layer),
lower() {},
raise() {},
raiseToTop() {},
isDestroyed: () => false,
};
const listeners = new Set();
const viewer = {
imageryLayers: collection,
camera: {
moveEnd: { addEventListener: () => () => {} },
flyTo() {},
},
scene: {
postRender: {
addEventListener(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
},
primitives: createShellScene().primitives,
globe: { tilesLoaded: true },
requestRender() {},
},
};
class Provider {
constructor(options) {
this.options = options;
this.tilingScheme = new GeographicTilingScheme();
this.errorEvent = new Cesium.Event();
}
requestImage() {
return undefined;
}
}
const cesium = {
...createShellCesium(),
UrlTemplateImageryProvider: Provider,
GeographicTilingScheme,
Event: Cesium.Event,
Credit: Cesium.Credit,
Rectangle: Cesium.Rectangle,
};
const options = {
cesium,
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
arrayBuffer: async () => new ArrayBuffer(1),
}),
decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
createCanvas: mockCanvas,
};
const settle = async () => {
await flush();
for (let i = 0; i < 2; i++) for (const fn of [...listeners]) fn();
await flush();
};
return { viewer, providers, options, settle };
}
/** Show a frame of `manifest` and return the rendering and its globe. */
async function shownFrame(manifest) {
const globe = globeViewer();
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
});
const shown = rendering.setFrame(manifest, times[1]);
await globe.settle();
assert.equal(await shown, true);
return { ...globe, rendering };
}
const creditOf = (providers) => providers.at(-1).credit.html;
test('the imagery credit uses the source credit as plain text, else the built-in one', async () => {
for (const product of ['radar', 'lightning', 'clouds-regional', 'clouds']) {
for (const display of [undefined, { credit: 42 }]) {
const h = await shownFrame({
...base,
product,
...(display ? { display } : {}),
});
assert.equal(creditOf(h.providers), BUILT_IN[product].credit, product);
h.rendering.clear();
}
const h = await shownFrame({
...base,
product,
display: { credit: 'Example <network> & partners' },
});
assert.equal(
creditOf(h.providers),
'Example &lt;network&gt; &amp; partners',
);
h.rendering.clear();
}
});
test('a refreshed manifest updates the credit of the frame already shown', async () => {
for (const product of ['lightning', 'clouds']) {
const h = await shownFrame({
...base,
product,
display: { credit: 'First credit' },
});
assert.equal(creditOf(h.providers), 'First credit');
const again = (display) =>
h.rendering.setFrame(
{ ...base, product, ...(display ? { display } : {}) },
times[1],
);
assert.equal(await again({ credit: 'Second credit' }), true);
assert.equal(h.providers.length, 1, 'the frame is not restaged');
assert.equal(creditOf(h.providers), 'Second credit');
assert.equal(await again(), true);
assert.equal(creditOf(h.providers), BUILT_IN[product].credit);
h.rendering.updateSnapshot({
...base,
product,
display: { credit: 'Third credit' },
});
assert.equal(creditOf(h.providers), 'Third credit');
// Another product's manifest leaves this frame alone.
h.rendering.updateSnapshot({
...base,
product: 'radar',
display: { credit: 'Other' },
});
assert.equal(creditOf(h.providers), 'Third credit');
h.rendering.clear();
}
});
test('a refreshed manifest leaves the shown snapshot for a host switch to restage', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const staged = [];
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame(snapshot, time) {
staged.push([snapshot, time]);
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
await globe.settle();
assert.equal(await shown, true);
const refreshed = {
...first,
times: [times[1]],
bounds: { west: -10, south: 0, east: 10, north: 5 },
display: { credit: 'Second credit' },
};
rendering.updateSnapshot(refreshed);
assert.equal(creditOf(globe.providers), 'Second credit');
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
assert.deepEqual(staged, [[first, times[0]]]);
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
await globe.settle();
assert.equal(globe.providers.length, 2);
const { west, south, east, north } = first.bounds;
assert.ok(
Cesium.Rectangle.equals(
globe.providers.at(-1).options.rectangle,
Cesium.Rectangle.fromDegrees(west, south, east, north),
),
'the restaged frame keeps the bounds it was staged from',
);
assert.equal(creditOf(globe.providers), 'Second credit');
rendering.clear();
});
test('a refresh during a host-switch restage keeps the newest credit', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame() {
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
await globe.settle();
assert.equal(await shown, true);
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
rendering.updateSnapshot({ ...first, display: { credit: 'Third credit' } });
await globe.settle();
assert.equal(globe.providers.length, 2);
assert.equal(creditOf(globe.providers), 'Third credit');
rendering.clear();
});
test('a refresh while a frame loads survives a later host switch', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame() {
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
await globe.settle();
assert.equal(await shown, true);
assert.equal(creditOf(globe.providers), 'Second credit');
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
await globe.settle();
assert.equal(globe.providers.length, 2);
assert.equal(creditOf(globe.providers), 'Second credit');
rendering.clear();
});
test('a refresh before a frame is installed gives it the newest credit', async () => {
for (const product of ['lightning', 'clouds']) {
const globe = globeViewer();
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
});
const first = { ...base, product, display: { credit: 'First credit' } };
const shown = rendering.setFrame(first, times[1]);
rendering.updateSnapshot({
...first,
display: { credit: 'Second credit' },
});
await globe.settle();
assert.equal(await shown, true);
assert.equal(creditOf(globe.providers), 'Second credit', product);
rendering.clear();
}
});
test('a layer refresh with the same latest frame updates the imagery credit', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const globe = globeViewer();
let manifest = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const layer = createWeatherLayer({
id: 'weather-lightning',
feed: { getSnapshot: async () => manifest },
documentRef: quiet(),
eventTarget: quiet(),
matchMedia: () => quiet(),
createRendering: (args) =>
createWeatherRendering({ ...args, ...globe.options }),
});
layer.init(globe.viewer);
layer.enable();
const first = layer.update();
await globe.settle();
await first;
assert.equal(creditOf(globe.providers), 'First credit');
manifest = { ...manifest, display: { credit: 'Second credit' } };
await layer.update();
assert.equal(globe.providers.length, 1, 'the frame is not restaged');
assert.equal(creditOf(globe.providers), 'Second credit');
manifest = { ...base, product: 'lightning' };
await layer.update();
assert.equal(creditOf(globe.providers), BUILT_IN.lightning.credit);
layer.destroy();
});
+85 -49
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import { weatherDisplay, insideExtent } from './display.js';
import {
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
@@ -371,6 +372,8 @@ export function createWeatherLayer({
}
manifest = snapshot;
error = null;
// The shown frame may stay; its credit follows the refreshed manifest.
rendering?.updateSnapshot?.(snapshot);
if (clock) {
await clock.refresh();
if (
@@ -467,9 +470,11 @@ export function createWeatherLayer({
manifest?.bounds &&
runNavigation
) {
const b = lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds;
const b =
weatherDisplay(manifest).extent ??
(lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds);
runNavigation(() =>
viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees(
@@ -526,6 +531,8 @@ export function createWeatherLayer({
getRowControls() {
const shared = clock?.getState();
const followLatest = isLatest();
// Source-described text is data: shown as is, never translated.
const display = weatherDisplay(manifest);
const missing =
noFrame &&
shared?.mode === 'history' &&
@@ -592,7 +599,9 @@ export function createWeatherLayer({
lon > b.east ||
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
(display.extent
? !insideExtent(display.extent, lon, lat)
: lightning && lon > 0 && lon < 110));
let detail = time
? t('{state} · {time} · {lag}', {
state: followLatest ? t('Observed') : t('History'),
@@ -606,7 +615,9 @@ export function createWeatherLayer({
else if (relation === 'nearest')
detail = t('{detail} · nearest', { detail });
const infoHeading = radar
? t('RADAR REFLECTIVITY · dBZ')
? display.units !== undefined
? t('RADAR · {units}', { units: display.units })
: t('RADAR REFLECTIVITY · dBZ')
: lightning
? t('LIGHTNING DENSITY · 15 min accumulation')
: product === 'clouds'
@@ -631,13 +642,24 @@ export function createWeatherLayer({
: [t('Observation: unavailable'), loading ? t('loading') : '']
.filter(Boolean)
.join(' · ');
const infoCoverage = radar
? t('Contiguous US · gaps ≠ no rain')
: lightning
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
: product === 'clouds'
? t('60°S–60°N · typically 2–3 h delayed')
: t('North America · infrared imagery');
const coverageLine =
display.coverage ??
(radar
? t('Contiguous US · gaps ≠ no rain')
: lightning
? t('Americas + Pacific · not individual strikes')
: product === 'clouds'
? t('60°S–60°N · typically 2–3 h delayed')
: t('North America · infrared imagery'));
const infoCoverage = lightning
? `${coverageLine}\n${
display.units === undefined
? t('Color: strikes/km²/min ×10³')
: t('Color: {units}', { units: display.units })
}`
: coverageLine;
const stops =
display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []);
const infoMotion = motion?.matches
? clock
? t('Reduced motion · history playback unavailable')
@@ -651,13 +673,15 @@ export function createWeatherLayer({
: lightning
? t('Lightning density · 15 min')
: t('Satellite clouds'),
coverage: radar
? 'CONUS'
: lightning
? t('Americas + Pacific')
: product === 'clouds'
? t('Global · 60°S–60°N')
: t('North America'),
coverage:
display.coverage ??
(radar
? 'CONUS'
: lightning
? t('Americas + Pacific')
: product === 'clouds'
? t('Global · 60°S–60°N')
: t('North America')),
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail,
@@ -674,7 +698,11 @@ export function createWeatherLayer({
: outside
? t('Map center outside coverage')
: null),
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
units:
display.units ??
(radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : ''),
// Source units are data: the panel shows them untranslated.
...(display.units !== undefined ? { unitsVerbatim: true } : {}),
},
chips: [
...(satellite
@@ -723,43 +751,47 @@ export function createWeatherLayer({
})),
{
id: 'coverage',
label: radar
? t('View US radar')
: lightning
? t('View Americas & Pacific')
: t('View coverage'),
label:
display.coverage !== undefined || display.extent !== undefined
? t('View coverage')
: radar
? t('View US radar')
: lightning
? t('View Americas & Pacific')
: t('View coverage'),
disabled: !manifest || !runNavigation,
params: { focus: true },
},
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label),
})
: t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label),
}),
}))
: [],
legend: stops.map(([label, color]) => ({
label: String(label),
color,
blurb: display.legend
? `${formatScaleLabel(label)} ${display.units}`
: lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label),
})
: t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label),
}),
})),
info: hostHidden
? shownHost
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
infoTitle: lightning
? t(
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
)
: radar
infoTitle:
display.info ??
(lightning
? t(
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
)
: t(
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
),
: radar
? t(
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
)
: t(
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
)),
};
controls.summary.settings = [
...(satellite
@@ -805,6 +837,7 @@ export function createWeatherLayer({
listener = typeof value === 'function' ? value : null;
},
getStats() {
const { source } = weatherDisplay(manifest);
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? t('Observed') : t('History'),
@@ -812,7 +845,10 @@ export function createWeatherLayer({
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
...(source === undefined
? { source: 'NOAA nowCOAST' }
: // Source text is data: the panel shows it untranslated.
{ source, sourceVerbatim: true }),
observedAt: shownTime(),
};
},
+83 -13
View File
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
import { imageryHostStatus } from './imageryHost.js';
import { createRasterTileProvider } from './rasterTiles.js';
import { createWeatherShell } from './shellRendering.js';
import { weatherDisplay, escapeCreditText } from './display.js';
import { msg } from '../../i18n/index.js';
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import {
@@ -116,6 +117,38 @@ function createGlobeRendering({
return urls;
}
function creditText(snapshot) {
const sourceCredit = weatherDisplay(snapshot).credit;
return sourceCredit !== undefined
? escapeCreditText(sourceCredit)
: snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners';
}
function setCredit(frame) {
const text = creditText(frame.creditSnapshot ?? frame.snapshot);
if (frame.credit?.html === text) return false;
frame.credit = new cesium.Credit(text, false);
return true;
}
/**
* Take the credit of a refreshed manifest of the same product. Each frame
* keeps the snapshot it was staged from, whose time and bounds still match,
* and remembers the refreshed one for its credit, including a frame that
* is still loading and takes its credit when installed.
*/
function updateSnapshot(snapshot) {
let changed = false;
for (const frame of new Set([current, incoming, ...retiring])) {
if (!frame || frame.product !== snapshot.product) continue;
frame.creditSnapshot = snapshot;
if (frame.credit && setCredit(frame)) changed = true;
}
if (changed) viewer.scene.requestRender();
}
function remove(frame) {
if (!frame) return;
frame.closed = true;
@@ -180,6 +213,7 @@ function createGlobeRendering({
return {
rehome,
cancelPrefetch,
updateSnapshot,
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
let job;
try {
@@ -220,7 +254,11 @@ function createGlobeRendering({
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
async setFrame(
snapshot,
time,
{ signal, infrared = 'filtered', creditSnapshot } = {},
) {
signal?.throwIfAborted();
cancelPrefetch();
rehome();
@@ -234,14 +272,17 @@ function createGlobeRendering({
current.product === snapshot.product &&
current.kind === kind &&
current.infrared === infrared
)
) {
updateSnapshot(creditSnapshot ?? snapshot);
return true;
}
lastError = null;
const { west, south, east, north } = snapshot.bounds;
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
const global = snapshot.product === 'clouds';
const frame = {
snapshot,
creditSnapshot,
time,
infrared,
mosaic: global
@@ -342,14 +383,8 @@ function createGlobeRendering({
}
: undefined,
);
const credit = new cesium.Credit(
snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
false,
);
setCredit(frame);
const credit = frame.credit;
const provider = global
? createRasterTileProvider({
cesium,
@@ -373,6 +408,13 @@ function createGlobeRendering({
enablePickFeatures: false,
credit,
});
// Cesium reads a provider's credit every frame; a refreshed manifest
// can replace it without restaging the imagery.
Object.defineProperty(provider, 'credit', {
configurable: true,
enumerable: true,
get: () => frame.credit,
});
const requestImage = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (frame.closed) return undefined;
@@ -574,6 +616,8 @@ export function createWeatherRendering({
let hidden = false;
let shown = null;
let restaging = null;
// The last refreshed manifest, so a frame shown after it keeps its credit.
let refreshed = null;
// A host without imagery keeps the active renderer, which retains its frame.
const wanted = () => {
@@ -602,8 +646,13 @@ export function createWeatherRendering({
restaging = null;
if (!ok) onChange();
};
// The restaged frame takes the newest refreshed credit; a refresh while
// it loads reaches it through the globe's own updateSnapshot.
target
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
.setFrame(frame.snapshot, frame.time, {
infrared: frame.infrared,
...(target === globe ? { creditSnapshot: shown?.latest } : {}),
})
.then(settle, () => settle(false));
}
function switchHost() {
@@ -645,11 +694,31 @@ export function createWeatherRendering({
switchHost();
restaging = null;
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
const before = refreshed;
const ok = await target.setFrame(snapshot, time, options);
if (ok)
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
if (ok) {
// Keep a refresh that arrived while this frame loaded.
const latest =
refreshed !== before && refreshed?.product === snapshot.product
? refreshed
: undefined;
shown = {
snapshot,
time,
infrared: options.infrared ?? 'filtered',
...(latest ? { latest } : {}),
};
}
return ok;
},
// The shown frame keeps its staged snapshot for a restage on another
// host; only the refreshed credit is remembered and applied.
updateSnapshot(snapshot) {
refreshed = snapshot;
if (shown?.snapshot.product === snapshot.product)
shown = { ...shown, latest: snapshot };
globe.updateSnapshot(snapshot);
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) restaging = null;
@@ -662,6 +731,7 @@ export function createWeatherRendering({
clear() {
restaging = null;
shown = null;
refreshed = null;
hidden = false;
shell?.clear();
shell = null;
+2 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import {
CUBE_FACES,
@@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn
}));
for (const face of CUBE_FACES) {
const { data, info } = await sharp(
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)),
)
.raw()
.toBuffer({ resolveWithObject: true });
+5 -1
View File
@@ -635,7 +635,11 @@ export class LayerPanel {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
const source = sourceLabel(stats.source || layer.source);
// A layer marks source text that is data rather than a catalog name.
const source =
stats.sourceVerbatim === true && stats.source
? stats.source
: sourceLabel(stats.source || layer.source);
const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
+18
View File
@@ -87,3 +87,21 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('a source a layer marks as data is shown untranslated in the row', () => {
setLocale('ru');
try {
const row = (stats) =>
LayerPanel.prototype._buildMetaText({
id: 'weather-lightning',
enabled: true,
stats: { count: 1, lastUpdate: Date.now(), ...stats },
});
assert.match(row({ source: 'Local ADS-B' }), /Локальный ADS-B/);
const verbatim = row({ source: 'Local ADS-B', sourceVerbatim: true });
assert.match(verbatim, /Local ADS-B/);
assert.doesNotMatch(verbatim, /Локальный/);
} finally {
setLocale('en');
}
});
+10 -4
View File
@@ -16,9 +16,10 @@ const order = (parent, nodes) =>
parent.insertBefore(node, parent.children[i] || null);
});
// "960 — 1050+ гПа": the unit stays with the last number.
const scaleText = (low, high, units) => {
const unit = unitLabel(units);
// "960 — 1050+ гПа": the unit stays with the last number. Units a source
// supplied as data are shown as given.
const scaleText = (low, high, units, verbatim) => {
const unit = verbatim ? units || '' : unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range}\u00a0${unit}` : range;
};
@@ -309,7 +310,12 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
block.scale,
'textContent',
visible
? scaleText(legend.labels[0], legend.labels.at(-1), legend.units)
? scaleText(
legend.labels[0],
legend.labels.at(-1),
legend.units,
legend.unitsVerbatim === true,
)
: '',
);
} else if (props.type === 'actions') {
+38 -8
View File
@@ -21,6 +21,9 @@ export class SceneControls {
this.rowRemovers = [];
this.playbackKeyRemover = null;
if (!elements.select) return;
// The radio's PLAY means "listen"; this one plays the scene.
const playLabel = t('PLAY', { context: 'scene' });
if (elements.start) elements.start.textContent = playLabel;
if (subscribe)
this.unsubscribe = subscribe((notification) =>
this.present(notification),
@@ -94,7 +97,21 @@ export class SceneControls {
this.renderShotList();
if (type === 'selection-changed')
presentSceneSelection(this.elements.shots, state.selectedShotId);
if (initial || type === 'buttons-changed') this.setButtons(state.running);
if (
initial ||
[
'buttons-changed',
'selection-changed',
'scene-options-changed',
'scene-created',
'scene-deleted',
'shots-changed',
'shot-captured',
'shot-deleted',
'project-imported',
].includes(type)
)
this.setButtons(state.running);
if (initial || type === 'progress-changed')
this.setProgress(state.progress);
if (initial || type === 'status-changed') this.updateStatus(state.status);
@@ -183,11 +200,9 @@ export class SceneControls {
this.listen(element, type, callback, this.rowRemovers),
select: (id) => {
this.run('selectShot', id);
if (!this.destroyed)
presentSceneSelection(
this.elements.shots,
this.read().selectedShotId,
);
if (this.destroyed) return;
presentSceneSelection(this.elements.shots, this.read().selectedShotId);
this.setButtons(this.read().running);
},
rename: (sceneId, shotId, title) =>
this.run('renameShot', sceneId, shotId, title),
@@ -197,8 +212,22 @@ export class SceneControls {
}
setButtons(running) {
if (!this.destroyed)
presentSceneButtons(this.elements, running, this.read().hasRun);
if (this.destroyed) return;
const state = this.read();
const scene = state.scenes?.find(
(item) => item.id === state.selectedSceneId,
);
const shots = scene?.shots ?? [];
presentSceneButtons(this.elements, running, state.hasRun, {
hasScene: !!scene,
// PLAY and NEXT run on into the following scenes.
playableShots: (state.scenes ?? []).reduce(
(total, item) => total + (item.shots?.length ?? 0),
0,
),
hasSelectedShot: shots.some((shot) => shot.id === state.selectedShotId),
});
this.elements.panel?.classList.toggle('has-shots', shots.length > 0);
}
setProgress(progress) {
@@ -247,6 +276,7 @@ export class SceneControls {
])
remove();
if (this.elements.shots) this.elements.shots.textContent = '';
this.elements.panel?.classList.toggle('has-shots', false);
presentSceneRuntime(this.elements.runtime, '');
this.setPlaybackActive(false);
}
+181
View File
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
import { createStateChannel } from '../app/stateChannel.js';
import { SceneControls } from './sceneControls.js';
import { SceneDirector } from '../scenes/director.js';
import { mountSceneSharing } from './sceneSharing.js';
import { setLocale } from '../i18n/index.js';
function fixture() {
class Element extends EventTarget {
@@ -169,6 +171,46 @@ test('Scene controls render the supplied selection and running/download states',
}
});
test('Authoring disables idle actions that would do nothing', () => {
const f = fixture();
try {
assert.equal(f.elements.start.textContent, 'PLAY');
assert.equal(f.elements.start.disabled, false);
assert.equal(f.elements.update.disabled, false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
f.state.selectedShotId = null;
f.owner.setButtons(false);
assert.equal(f.elements.update.disabled, true);
assert.equal(f.elements.start.disabled, false);
// PLAY and NEXT run on into the following scenes, so an empty selected
// scene beside one with shots keeps them available.
f.state.scenes[0].shots = [];
f.state.scenes.push({ id: 'scene-b', title: 'B', shots: [{ id: 'b1' }] });
f.owner.setButtons(false);
assert.equal(f.elements.start.disabled, false);
assert.equal(f.elements.next.disabled, false);
assert.equal(f.elements.update.disabled, true);
f.state.scenes.pop();
f.owner.setButtons(false);
assert.equal(f.elements.start.disabled, true);
assert.equal(f.elements.next.disabled, true);
assert.equal(f.elements.capture.disabled, false);
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
f.state.scenes = [];
f.state.selectedSceneId = null;
f.owner.setButtons(false);
assert.equal(f.elements.delete.disabled, true);
assert.equal(f.elements.capture.disabled, true);
assert.equal(f.elements.new.disabled, false);
} finally {
f.restore();
}
});
test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => {
const f = fixture();
try {
@@ -435,3 +477,142 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
f.restore();
}
});
test('Authoring refreshes idle buttons from published changes', () => {
const f = fixture();
try {
f.owner.destroy();
const channel = createStateChannel(() => f.state);
const owner = new SceneControls({
read: () => f.state,
actions: f.actions,
elements: f.elements,
subscribe: (listener) => channel.subscribe(listener),
});
const disabled = (name) => f.elements[name].disabled;
assert.equal(disabled('start'), false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
f.state.selectedShotId = null;
channel.publish({ type: 'selection-changed' });
assert.equal(disabled('update'), true);
f.state.scenes[0].shots = [];
channel.publish({ type: 'shot-deleted' });
assert.equal(disabled('start'), true);
assert.equal(disabled('next'), true);
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
// A run that ends hands the buttons back with the idle rules applied.
f.state.running = true;
channel.publish({ type: 'buttons-changed' });
assert.equal(disabled('capture'), true);
assert.equal(disabled('stop'), false);
f.state.running = false;
channel.publish({ type: 'buttons-changed' });
assert.equal(disabled('capture'), false);
assert.equal(disabled('start'), true);
f.state.scenes = [];
f.state.selectedSceneId = null;
channel.publish({ type: 'scene-deleted' });
assert.equal(disabled('delete'), true);
assert.equal(disabled('capture'), true);
assert.equal(disabled('new'), false);
f.state.scenes = [{ id: 'scene-b', title: 'B', shots: [{ id: 's' }] }];
f.state.selectedSceneId = 'scene-b';
f.state.selectedShotId = 's';
channel.publish({ type: 'project-imported' });
assert.equal(disabled('start'), false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
owner.destroy();
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
channel.destroy();
} finally {
f.restore();
}
});
test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => {
context.after(() => setLocale('en'));
setLocale('es');
const f = fixture();
try {
assert.equal(f.elements.start.textContent, 'REPROD.');
} finally {
f.restore();
}
});
test('The sharing row sits under SAVE & SHARE, after the file row', () => {
const saved = globalThis.document;
const node = (className = '') => {
const element = {
className,
children: [],
dataset: {},
parent: null,
addEventListener() {},
removeEventListener() {},
append(...items) {
for (const item of items) {
item.parent = element;
element.children.push(item);
}
},
appendChild(item) {
element.append(item);
},
after(item) {
const siblings = element.parent.children;
item.parent = element.parent;
siblings.splice(siblings.indexOf(element) + 1, 0, item);
},
remove() {
const siblings = element.parent?.children;
if (siblings) siblings.splice(siblings.indexOf(element), 1);
},
querySelector(selector) {
const name = selector.slice(1);
const walk = (at) => {
for (const child of at.children) {
if (child.className.split(' ').includes(name)) return child;
const found = walk(child);
if (found) return found;
}
return null;
};
return walk(element);
},
};
return element;
};
globalThis.document = { createElement: () => node() };
try {
const panel = node();
const inner = node('scene-panel-inner');
const fileRow = node('scene-controls scene-file-controls');
const status = node('scene-status');
panel.append(inner);
inner.append(fileRow, status);
const dispose = mountSceneSharing({ edit() {}, share() {} }, panel);
const bar = inner.children[1];
assert.equal(bar.className, 'scene-controls');
assert.equal(inner.children[2], status);
assert.ok(bar.children.every((button) => button.title));
dispose();
assert.deepEqual(inner.children, [fileRow, status]);
// Without the file row, the bar still lands inside the panel.
const plain = node();
const plainInner = node('scene-panel-inner');
plain.append(plainInner);
mountSceneSharing({ edit() {}, share() {} }, plain)();
mountSceneSharing({ edit() {}, share() {} }, plain);
assert.equal(plainInner.children.length, 1);
} finally {
globalThis.document = saved;
}
});
+21 -2
View File
@@ -171,7 +171,26 @@ export function presentSceneSelection(element, selectedShotId) {
}
}
export function presentSceneButtons(elements, running, hasRun) {
/**
* While a scene plays, the authoring and file actions here are off and STOP
* is on. Otherwise a button is off when it would do nothing: DELETE and
* CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot, PLAY and
* NEXT when no scene in the project has a shot (both continue into the
* following scenes).
*/
export function presentSceneButtons(
elements,
running,
hasRun,
{ hasScene = true, playableShots = 1, hasSelectedShot = true } = {},
) {
const idleOff = {
delete: !hasScene,
capture: !hasScene,
update: !hasSelectedShot,
start: playableShots === 0,
next: playableShots === 0,
};
for (const name of [
'start',
'next',
@@ -183,7 +202,7 @@ export function presentSceneButtons(elements, running, hasRun) {
'export',
'import',
]) {
if (elements[name]) elements[name].disabled = running;
if (elements[name]) elements[name].disabled = running || !!idleOff[name];
}
if (elements.stop) elements.stop.disabled = !running;
if (elements.download) elements.download.disabled = !hasRun;
+16 -5
View File
@@ -181,17 +181,28 @@ export function mountSceneSharing(
bar.dataset.directorAuthoring = '';
bar.className = 'scene-controls';
const entries = [
[t('EDIT DETAILS'), edit],
[t('SHARE SCENE'), share],
[t('EDIT DETAILS'), t('Edit anchors, data packs and shot details'), edit],
[
t('SHARE SCENE'),
t('Download this scene as JSON or an asset bundle'),
share,
],
];
const removers = [];
for (const [text, fn] of entries) {
const b = createActionButton({ label: text, className: 'scene-btn' });
for (const [text, title, fn] of entries) {
const b = createActionButton({
label: text,
title,
className: 'scene-btn',
});
b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn));
bar.append(b);
}
(panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
// Beside EXPORT / IMPORT, under SAVE & SHARE.
const fileRow = panel.querySelector?.('.scene-file-controls');
if (fileRow) fileRow.after(bar);
else (panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
return () => {
for (const remove of removers) remove();
bar.remove();
+122 -24
View File
@@ -1,6 +1,8 @@
/* ── Scene Director Panel ───────────────────── */
#scene-panel {
--panel-expanded-width: 360px;
/* Secondary text in Authoring: dimmer than labels, but legible on the globe. */
--scene-quiet: rgba(232, 234, 237, 0.62);
position: fixed;
top: calc(var(--left-stack-top) + (var(--left-stack-gap) * 2));
left: var(--left-stack-x);
@@ -150,15 +152,15 @@
flex-wrap: wrap;
}
/* The scene's name takes its own row; NEW SCENE / DELETE share the next. */
#scene-select {
flex: 1;
flex: 1 1 100%;
min-width: 0;
min-height: 32px;
font-size: 12px;
}
/* A translated scene name needs the width: the select takes its own row,
NEW / DEL share the next one. */
html:not([lang='en']) #scene-select {
flex: 1 1 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -168,8 +170,7 @@ html:not([lang='en']) #scene-select {
}
/* A translated action longer than its button wraps inside the button instead
of running past it. English keeps its one-line buttons ("EXPORT PRESETS"
included). */
of running past it. English keeps its one-line buttons. */
html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
@@ -180,6 +181,70 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
min-width: 72px;
}
#scene-panel .scene-btn {
font-size: 10px;
padding: 8px 10px;
}
/* Authoring's steps: SCENE, SHOTS, PREVIEW, SAVE & SHARE. */
.scene-step-heading {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 6px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1.6px;
color: var(--scene-quiet);
}
.scene-step-heading::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.08);
}
.panel-header + .scene-step-heading {
margin-top: 2px;
}
/* One primary action per step: CAPTURE SHOT and PLAY. The panel id keeps
this above Cyber's shared button surface. */
#scene-panel .scene-btn-primary:not(:disabled) {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
/* The file row is housekeeping; it stays quieter than the steps above. */
#scene-panel .scene-file-controls .scene-btn {
font-size: 9px;
/* Room for one long translated word (IMPORTIEREN, ІМПОРТУВАТИ) per line. */
padding: 8px 4px;
letter-spacing: 0.6px;
background: transparent;
}
/* Keep the shared hover on the restyled actions. */
#scene-panel .scene-btn-primary:hover:not(:disabled),
#scene-panel .scene-file-controls .scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
.scene-shot-hint {
margin: 0 0 8px;
font-size: 11px;
line-height: 1.4;
color: var(--scene-quiet);
}
#scene-panel:not(.has-shots) .scene-shot-hint {
display: none;
}
#scene-start-btn,
#scene-stop-btn,
#scene-next-btn {
@@ -190,12 +255,14 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 160px;
min-height: 96px;
max-height: 260px;
/* Two shot rows stay visible; the panel scrolls when space is short. */
min-height: 128px;
flex: 1;
overflow-y: auto;
padding: 4px 2px 6px;
margin-bottom: 8px;
margin-bottom: 4px;
counter-reset: scene-shot;
}
.scene-shot-list::-webkit-scrollbar {
@@ -210,19 +277,21 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
.scene-shot-empty {
border: 1px dashed rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 10px 8px;
padding: 16px 12px;
text-align: center;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: var(--text-dim);
font-size: 11px;
letter-spacing: 0.3px;
line-height: 1.5;
color: var(--scene-quiet);
}
.scene-shot-row {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 9px;
padding: 7px 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
counter-increment: scene-shot;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
@@ -243,12 +312,38 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
.scene-shot-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1px;
font-size: 12px;
letter-spacing: 0.4px;
color: var(--text-primary);
}
/* While renaming, the label holds only the editor at full width. */
.scene-shot-label:has(.scene-shot-rename) {
overflow: visible;
white-space: normal;
}
.scene-shot-label:has(.scene-shot-rename)::before {
content: none;
}
/* Shots are numbered 01, 02… in playback order. */
.scene-shot-label::before {
content: counter(scene-shot, decimal-leading-zero);
margin-right: 8px;
font-size: 10px;
color: var(--scene-quiet);
}
.scene-shot-row.active .scene-shot-label::before {
color: var(--accent);
}
.scene-shot-rename {
width: 100%;
min-width: 0;
@@ -260,11 +355,11 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
}
.scene-shot-meta {
margin-top: 4px;
margin-top: 5px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.8px;
color: var(--text-dim);
font-size: 10px;
letter-spacing: 0.4px;
color: var(--scene-quiet);
}
.scene-shot-actions {
@@ -275,7 +370,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
.scene-progress {
margin-top: 8px;
width: 100%;
height: 18px;
height: 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -305,9 +400,10 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#scene-status {
margin-top: 8px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--text-secondary);
font-size: 11px;
letter-spacing: 0.4px;
line-height: 1.4;
color: var(--scene-quiet);
}
#scene-runtime {
@@ -357,6 +453,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#cctv-panel.collapsed .cctv-summary-label,
#cctv-panel.collapsed #cctv-summary,
#scene-panel.collapsed .scene-controls,
#scene-panel.collapsed .scene-step-heading,
#scene-panel.collapsed .scene-shot-hint,
#scene-panel.collapsed .scene-shot-list,
#scene-panel.collapsed .scene-progress,
#scene-panel.collapsed #scene-status,
+19 -14
View File
@@ -147,35 +147,40 @@
<div class="panel-glow"></div>
<div class="scene-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n>SCENES</span>
<span class="panel-title" title="Create and edit scenes" data-i18n data-i18n-title>AUTHORING</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<p class="scene-step-heading" data-i18n>SCENE</p>
<div class="scene-controls">
<select class="gev-field" id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>DEL</button>
<select class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label></select>
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" title="Start a new, empty scene" data-i18n data-i18n-title>NEW SCENE</button>
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Delete this scene and all of its shots" data-i18n data-i18n-title>DELETE</button>
</div>
<p class="scene-step-heading" data-i18n>SHOTS</p>
<div class="scene-controls">
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" data-i18n>UPDATE SHOT</button>
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Add the current view, style and layers as the next shot" data-i18n data-i18n-title>+ CAPTURE SHOT</button>
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" title="Replace the selected shot with the current view" data-i18n data-i18n-title>UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<p class="scene-shot-hint" data-i18n>Click a shot to select it, double-click to rename.</p>
<p class="scene-step-heading" data-i18n>PREVIEW</p>
<div class="scene-controls">
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn scene-btn-primary" title="Play from this scene through the rest of the project" data-i18n-title data-i18n-runtime>PLAY</button>
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" title="Stop playback (Esc)" data-i18n data-i18n-title>STOP</button>
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" title="Play the shot after the selected one, moving on to the next scene when needed" data-i18n data-i18n-title>NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" data-i18n>RUN LOG</button>
<p class="scene-step-heading" data-i18n>SAVE &amp; SHARE</p>
<div class="scene-controls scene-file-controls">
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" title="Download every scene as a JSON file you can import later" data-i18n data-i18n-title>EXPORT</button>
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" title="Load scenes from an exported JSON file" data-i18n data-i18n-title>IMPORT</button>
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" title="Download the timing log of the last play" data-i18n data-i18n-title>RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">
<div id="scene-progress-fill">0%</div>
</div>
<div id="scene-status" data-i18n>Ready</div>
<div id="scene-status" role="status" aria-live="polite" data-i18n>Ready</div>
</div>
</div>
+1
View File
@@ -215,6 +215,7 @@ export function createWeatherPanel({
colors: legend.map(({ color }) => color),
labels: legend.map(({ label }) => label),
units: categorical ? '' : summary.units,
...(summary.unitsVerbatim === true ? { unitsVerbatim: true } : {}),
zeroIndex: legend.findIndex(({ label }) => label === '0'),
},
};
+24
View File
@@ -752,3 +752,27 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir
view.destroy();
layer.destroy();
});
test('source-supplied legend units are shown as given; catalog units are translated', () => {
setLocale('ru');
try {
const f = fixture();
const view = createWeatherPanel(f);
const row = (summary) => ({
...radar,
summary: { ...radar.summary, units: 'km/h', ...summary },
legend: [
{ label: '1', color: '#000000' },
{ label: '2', color: '#ffffff' },
],
});
const scale = () =>
f.find((n) => n.className === 'rail-card-scale').textContent;
view.update([row()]);
assert.equal(scale(), '1 — 2 км/ч');
view.update([row({ unitsVerbatim: true })]);
assert.equal(scale(), '1 — 2 km/h');
view.destroy();
} finally {
setLocale('en');
}
});
+1
View File
@@ -97,6 +97,7 @@ const PANEL_ALIASES = new Map([
['global context', 'global-context-panel'],
['right context', 'global-context-panel'],
['context right panel', 'global-context-panel'],
['authoring', 'scene-panel'],
['scenes', 'scene-panel'],
['scene', 'scene-panel'],
['post processing', 'pp-toggles'],
+23
View File
@@ -3931,3 +3931,26 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.ok, true);
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
});
test('set_panel_open accepts the Authoring name for the scene panel', async () => {
const calls = [];
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000),
},
},
styleManager: {
setPanelCollapsed: (...args) => calls.push(args),
},
dataManager: { layers: new Map(), isEnabled: () => false, getAll: () => [] },
});
for (const panel of ['authoring', 'Authoring', 'scenes']) {
const result = await runner('set_panel_open', { panelId: panel, open: true });
assert.equal(result.panelId, 'scene-panel');
}
assert.deepEqual(calls.at(-1), ['scene-panel', false, { explicit: true }]);
});