mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce1320fe8 | ||
|
|
99370c29da | ||
|
|
0d1a68160e | ||
|
|
70494698bc | ||
|
|
903f59302b | ||
|
|
039b0828b7 | ||
|
|
3cd7d0fe15 | ||
|
|
544027053d | ||
|
|
3824a47df8 | ||
|
|
f52e2e62ee | ||
|
|
20ad658f6c | ||
|
|
b4c75749e8 | ||
|
|
684dff3b55 | ||
|
|
8978801dfd | ||
|
|
691f27387b | ||
|
|
cb6fd7568a | ||
|
|
84028f71fe | ||
|
|
e1bbbcec5a | ||
|
|
a725178e0f | ||
|
|
3119b2cf9c | ||
|
|
a4820315d9 | ||
|
|
2b05461232 | ||
|
|
5a80facf8d | ||
|
|
8de75b172a | ||
|
|
4192a6f87d | ||
|
|
452292001a | ||
|
|
2b1129c10e | ||
|
|
da2a3c3379 | ||
|
|
76a3d47e56 | ||
|
|
d1bbfdbca5 |
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
# 🌐 God's Eye View
|
||||
|
||||
**Official site: [godseyeview.ai](https://godseyeview.ai)** · Official source: this repository · Hosted version coming soon
|
||||
|
||||
[](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) [](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.**
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
- The right rail (Display, CCTV, Street Level, Context, plus Weather and Recent
|
||||
Imagery while shown) is a vertical strip of icon tabs instead of an accordion
|
||||
of collapsed launcher cards: at most one panel is open in every desktop HUD
|
||||
layout, and the open tab and its panel share one surface and outline, like a
|
||||
folder tab. On a short window the rail rises toward the HUD corner above it
|
||||
rather than scroll. A first-time visitor on a desktop finds Display open; a
|
||||
stored choice still wins. The panels' own collapse arrows are gone on desktop:
|
||||
the tab opens and closes a panel, Escape inside one closes it and returns
|
||||
focus to its tab, and the strip is a keyboard-navigable tablist. Saved
|
||||
preferences, share links and the phone sheet shell are unchanged (gekh,
|
||||
#1425).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
+66
-79
@@ -252,12 +252,11 @@ layouts retain their existing presentation.
|
||||
Chamfered panel expand/collapse controls use an inset keyboard-focus outline and
|
||||
red hover border in both map and cockpit, so clipping does not hide the highlight.
|
||||
|
||||
Cyber skins the map-side right rail's tab strip in its red and slate panel
|
||||
treatment; the one-panel rule applies in every desktop layout (see "Right rail
|
||||
tabs" below). Display, CCTV and Context scroll their contents
|
||||
inside fixed headers and decorative frames. The narrow-screen rail remains
|
||||
scrollable to reach each panel. Radio retains the shared nested Context player
|
||||
without relocating playback controls on theme changes.
|
||||
Cyber's map-side right rail opens one panel at a time while keeping collapsed
|
||||
launchers available. Display, CCTV and Context scroll their contents inside fixed
|
||||
headers and decorative frames. The narrow-screen rail remains scrollable to reach
|
||||
each panel. Radio retains the shared nested Context player without relocating
|
||||
playback controls on theme changes.
|
||||
|
||||
Voice help/error popups and Location/Visual Presets pins extend beyond their
|
||||
Cyber frames without being clipped: the shapes decorate non-interactive
|
||||
@@ -476,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.
|
||||
@@ -545,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
|
||||
@@ -616,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.
|
||||
@@ -749,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.
|
||||
|
||||
@@ -763,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.
|
||||
|
||||
@@ -1191,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.
|
||||
@@ -1525,68 +1564,18 @@ retains observers, frame scheduling, saved/share preferences and Cockpit portals
|
||||
|
||||
The left rail keeps its measured collapsed heights and obstacle-safe corridor;
|
||||
the right rail follows its top baseline and preserves Display's scroll owner.
|
||||
The left rail's automatic collapse remains presentation only and prioritizes
|
||||
the latest explicit panel. The right rail keeps one expanded owner (the latest
|
||||
explicit panel, else the focused one, else one with a stored or shared choice
|
||||
over one open only by default, else the first) and presentation-collapses its
|
||||
peers with `rail-owner-collapsed`, which the phone shell reads as "expanded"
|
||||
and a share link carries as collapsed, so a recipient opens the panel the
|
||||
sender sees. Any live opening of a rail panel, with or without the explicit
|
||||
flag (a camera click, Street Level switching on, a radio pick), makes that
|
||||
panel the owner. Mobile layout still releases desktop allocation styles and
|
||||
gives every owner-collapsed panel back. Stable Display allocation avoids
|
||||
unnecessary style writes.
|
||||
The reveal after the expanded Radio Enable lands its smooth scroll even when a
|
||||
rail layout pass cuts it short.
|
||||
Automatic collapse remains presentation only, prioritizes the latest explicit
|
||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||
writes. This extraction does not change panel positions or layout defaults.
|
||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||
keeps the collapsed panels its own exclusive presentation hides, so the owner
|
||||
rule can still give them back.
|
||||
|
||||
### Right rail tabs
|
||||
|
||||
On desktop the right rail (Display, CCTV, Street Level, Context, plus Weather
|
||||
and Recent Imagery while shown) is a vertical tab strip
|
||||
(`src/ui/rightRailTabs.js`, `src/ui/styles/right-rail-tabs.css`): one upright
|
||||
icon tab per panel at the rail's right edge, the open panel beside it. A tab
|
||||
toggles its panel through `setPanelCollapsed` with the explicit flag; the
|
||||
panels' `collapsed` classes remain the only state, mirrored through a
|
||||
MutationObserver, and the owner rule above hides the rest. On desktop the
|
||||
panels' own header collapse
|
||||
buttons are hidden: the tab is the one control, Escape its keyboard twin.
|
||||
The strip is a vertical `role="tablist"`: Up/Down
|
||||
wrap, Home/End jump, the open tab (else the first) is the tab stop, Escape
|
||||
inside a panel returns focus to its tab, and the strip precedes the panels in
|
||||
the DOM so Tab from the open tab reaches the panel.
|
||||
Glyphs come from `RAIL_TAB_ICONS` (Material Symbols, in the `index.html`
|
||||
subset); a tab's accessible name and tooltip are the panel's translated header
|
||||
title. The tabs keep their size. The lane's top follows the left rail's; a
|
||||
strip taller than the lane lifts the rail toward the HUD corner above
|
||||
(`src/ui/rightPanelRail.js`), and on a lane still shorter the strip runs past
|
||||
its end; the strip never scrolls or clips.
|
||||
The strip marks the rail with `data-rail-tabs`, which
|
||||
the stylesheet keys the strip's column on; a rail without the strip keeps its
|
||||
plain geometry. Phones hide the strip and keep the sheet shell; Cockpit hides
|
||||
the rail and the strip with it.
|
||||
|
||||
The open tab and its panel are one surface, like a folder tab; the other tabs
|
||||
are borderless icons. The strip measures the open panel and tab, traces their
|
||||
joined silhouette as an SVG path (`railJoinPath`; Cyber gets its chamfered
|
||||
frame) and shapes a join element under the panels with it: glass, outline and
|
||||
shadow in one piece, so the junction has no seam. The panel and the tab draw
|
||||
no surface of their own. `--rail-tab-bottom` on the rail sets the open panel's
|
||||
minimum height, so a short panel such as Context still reaches its tab.
|
||||
|
||||
A first-time visitor on a desktop viewport finds Display open
|
||||
(`src/ui/panelPositionControls.js`): with every panel closed nothing shows that
|
||||
the icons are launchers. Phones keep it closed, a share link decides for
|
||||
itself, and a stored choice wins either way. When the rail picks its owner, a
|
||||
panel the visitor or a share link opened outranks one open only by default, so
|
||||
a stored CCTV choice is not displaced by the Display default.
|
||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||
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
|
||||
@@ -1617,10 +1606,9 @@ 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
|
||||
right rail hides no collapsed siblings, shows no tab strip and applies no
|
||||
one-panel rule at these widths, since the sheet chooses what shows. The Cesium
|
||||
and Google attribution stays visible above 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
|
||||
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
|
||||
Wider viewports are unchanged, and crossing to them closes any open sheet.
|
||||
@@ -2230,9 +2218,7 @@ which embeds the MAP_KEY.
|
||||
> Missions · Environmental · Explore manually**. No layer and no optional API
|
||||
> call happens until a tile is clicked. The right-hand DISPLAY rail
|
||||
> (`pp-toggles`) now starts **collapsed** on a first run rather than expanded —
|
||||
> a stored collapse state still wins, as before. (Superseded 2026-10-27: with
|
||||
> the right rail tabs DISPLAY starts open on a first desktop run; see "Right
|
||||
> rail tabs".)
|
||||
> a stored collapse state still wins, as before.
|
||||
>
|
||||
> **The ENVIRONMENTAL tile is quakes AND fires** — live USGS earthquakes plus
|
||||
> NASA FIRMS active fires (`layerIds: ['earthquakes', 'local-firms']`), with the
|
||||
@@ -3066,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
|
||||
@@ -4891,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)
|
||||
|
||||
@@ -5479,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.
|
||||
@@ -5664,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.
|
||||
@@ -5729,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.
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 404 KiB |
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,cloud,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,travel_explore,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- gev:template scene-chrome -->
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
@@ -1118,8 +1119,5 @@
|
||||
"src/testSupport/shareViewFixtures.mjs",
|
||||
"src/ui/shareLinks.test.mjs",
|
||||
"src/ui/shareButton.test.mjs",
|
||||
"src/share/shareLinkPath.test.mjs",
|
||||
"src/ui/rightRailTabs.js",
|
||||
"src/ui/rightRailTabs.test.mjs",
|
||||
"src/ui/styles/right-rail-tabs.css"
|
||||
"src/share/shareLinkPath.test.mjs"
|
||||
]
|
||||
@@ -424,7 +424,6 @@
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/mobileShellContract.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/panelStackLayout.js"
|
||||
@@ -824,8 +823,6 @@
|
||||
"src/ui/radioTunerModel.js",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/mobileShellContract.js",
|
||||
"src/ui/rightRailTabs.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
@@ -2493,8 +2490,6 @@
|
||||
"src/ui/radioTunerModel.js",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/mobileShellContract.js",
|
||||
"src/ui/rightRailTabs.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
@@ -2646,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",
|
||||
@@ -3278,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",
|
||||
@@ -3543,7 +3540,6 @@
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/ui/mobileShellContract.js",
|
||||
"src/bloom.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
@@ -3597,8 +3593,6 @@
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/mobileShellContract.js",
|
||||
"src/ui/rightRailTabs.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
|
||||
@@ -1779,6 +1779,18 @@ async function main() {
|
||||
|
||||
return result;
|
||||
});
|
||||
check(
|
||||
'HUD-off desktop lanes keep Data plus Scenes and Display plus Context expanded inside the viewport corridor',
|
||||
multiExpandedPanelLayout.independentLeft
|
||||
&& multiExpandedPanelLayout.independentRight
|
||||
&& multiExpandedPanelLayout.leftCountsAllExpanded
|
||||
&& multiExpandedPanelLayout.rightCountsAllExpanded
|
||||
&& multiExpandedPanelLayout.leftInsideCorridor
|
||||
&& multiExpandedPanelLayout.rightInsideCorridor
|
||||
&& multiExpandedPanelLayout.internalScrollOwned
|
||||
&& multiExpandedPanelLayout.restoredExactly,
|
||||
JSON.stringify(multiExpandedPanelLayout),
|
||||
);
|
||||
check(
|
||||
'HUD-off to Tactical preserves the latest-opened Scenes panel and hides its collapsed Data competitor',
|
||||
multiExpandedPanelLayout.tacticalAutoCollapse.latestOpenedPanel === 'scene-panel'
|
||||
@@ -1792,6 +1804,16 @@ async function main() {
|
||||
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextAriaExpanded === 'false',
|
||||
JSON.stringify(multiExpandedPanelLayout.tacticalAutoCollapse),
|
||||
);
|
||||
check(
|
||||
'closing Tactical lane owners restores eligible temporary siblings without changing disclosure truth',
|
||||
multiExpandedPanelLayout.tacticalAutoRestore.dataExpanded
|
||||
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAutoMarkerCleared
|
||||
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAriaExpanded === 'true'
|
||||
&& multiExpandedPanelLayout.tacticalAutoRestore.contextExpanded
|
||||
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAutoMarkerCleared
|
||||
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAriaExpanded === 'true',
|
||||
JSON.stringify(multiExpandedPanelLayout.tacticalAutoRestore),
|
||||
);
|
||||
check(
|
||||
'Tactical Display hides ordinary collapsed CCTV and Context launchers',
|
||||
Object.values(multiExpandedPanelLayout.tacticalDisplayExclusive).every(Boolean),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -184,12 +184,6 @@ test('only the expanded Radio Enable gesture requests the contained post-enable
|
||||
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
|
||||
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||
// A rail layout pass measuring the panels mid-scroll cancels the smooth
|
||||
// reveal; the helper lands it at the target when the scroll ends short.
|
||||
assert.match(
|
||||
radioControlsSource,
|
||||
/scroller\.addEventListener\(\s*'scrollend',[\s\S]*?Math\.abs\(scroller\.scrollTop - next\) >= 1\)\s*scroller\.scrollTop = next;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('right-rail context entry is transactional: activation result gates the mode', () => {
|
||||
|
||||
@@ -491,10 +491,8 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
|
||||
// to a mobile mode at the SAME breakpoint and removes both the class and the
|
||||
// custom property. Pin that, or the exemption above is unearned.
|
||||
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
|
||||
const contract = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'mobileShellContract.js'), 'utf8');
|
||||
assert.match(contract, /MOBILE_SHELL_MEDIA_QUERY = '\(max-width: 720px\)'/, 'the shell contract no longer names (max-width: 720px)');
|
||||
const gate = rail.indexOf('windowRef.matchMedia(MOBILE_SHELL_MEDIA_QUERY)');
|
||||
assert.ok(gate > 0, 'the rail layout pass no longer keys off the shell breakpoint');
|
||||
const gate = rail.indexOf("windowRef.matchMedia('(max-width: 720px)')");
|
||||
assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)');
|
||||
const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40);
|
||||
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
|
||||
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -983,19 +983,18 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
|
||||
assert.match(keyboard, /addEventListener\('keydown', onKeyDown, true\)/);
|
||||
});
|
||||
|
||||
test('the DISPLAY rail starts open on a first desktop run, and a stored choice wins', () => {
|
||||
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/panelPositionControls.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// The right rail is a strip of label tabs: with every panel closed nothing
|
||||
// shows that the labels are launchers, so DISPLAY opens on a first desktop
|
||||
// run to demonstrate the strip. Phones keep their sheets and a share link
|
||||
// decides for itself.
|
||||
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
||||
// now, so it was opening to offer controls for things already happening —
|
||||
// while competing with the mission card for the one first impression there is.
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(panelId === 'pp-toggles' && stored === null\)\s+collapsed =\s+!allowStored \|\|\s+globalThis\.matchMedia\?\.\(MOBILE_SHELL_MEDIA_QUERY\)\?\.matches === true;/,
|
||||
'first desktop run must open DISPLAY; phones and share links keep it closed',
|
||||
/if \(panelId === 'pp-toggles' && stored === null\) collapsed = true;/,
|
||||
'first run must leave DISPLAY collapsed',
|
||||
);
|
||||
// The first-run default may only apply when NOTHING is stored: a visitor who
|
||||
// opened the rail keeps it open, and one who closed it keeps it closed.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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またはアセットバンドルとしてダウンロード"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 или архивом ресурсов"
|
||||
}
|
||||
@@ -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 або архівом ресурсів"
|
||||
}
|
||||
@@ -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 或资源包"
|
||||
}
|
||||
@@ -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 或資源包"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)} · ${
|
||||
|
||||
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[
|
||||
ch
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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>`),
|
||||
'<b>"A" & 'B'</b>',
|
||||
);
|
||||
});
|
||||
|
||||
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 <network> & 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
@@ -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(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,20 +71,6 @@ test('Escape collapses an expanded panel and returns focus to its disclosure', (
|
||||
assert.equal(fixture.event.propagationStopped, true);
|
||||
});
|
||||
|
||||
test('Escape hands focus to the rail tab that controls the panel when one exists', () => {
|
||||
const fixture = panelFixture();
|
||||
let tabFocused = false;
|
||||
fixture.panel.ownerDocument = {
|
||||
querySelector: (selector) => (
|
||||
selector === '[role="tab"][aria-controls="data-panel"]' ? { focus: () => { tabFocused = true; } } : null
|
||||
),
|
||||
};
|
||||
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
|
||||
assert.equal(fixture.collapsed(), true);
|
||||
assert.equal(tabFocused, true);
|
||||
assert.equal(fixture.focused(), false, 'the hidden in-panel disclosure is not focused');
|
||||
});
|
||||
|
||||
test('Escape on an expanded panel disclosure closes it without retaining focus', () => {
|
||||
const fixture = panelFixture({ onDisclosure: true });
|
||||
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
|
||||
|
||||
@@ -162,15 +162,11 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
||||
assert.match(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
|
||||
assert.match(
|
||||
rightRail,
|
||||
/expanded\.find\(\(panel\) => panel\.id === preferredPanelId\)/,
|
||||
'the latest explicitly opened right panel must be the one expanded owner',
|
||||
/panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
|
||||
'the latest explicitly opened right panel must receive primary allocation',
|
||||
);
|
||||
assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/);
|
||||
assert.match(
|
||||
rightRail,
|
||||
/expanded\.find\(\(panel\) => panel\.contains\(documentRef\?\.activeElement\)\)/,
|
||||
'a focused right panel is the fallback owner so presentation collapse never strands focus',
|
||||
);
|
||||
assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/);
|
||||
assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
|
||||
assert.match(leftRail, /--left-panel-allocated-height/);
|
||||
assert.match(rightRail, /--right-panel-allocated-height/);
|
||||
@@ -202,8 +198,8 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
|
||||
|
||||
assert.match(
|
||||
ui,
|
||||
/const collapsed = panelEl\.classList\.contains\('layout-auto-collapsed'\)\s*\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||
'the left rail\'s responsive auto-collapse serializes the expanded preference; a right-rail peer hidden behind the owner travels as collapsed',
|
||||
/const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||
'responsive auto-collapse must serialize the explicit expanded preference',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js';
|
||||
|
||||
const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([
|
||||
'contacts',
|
||||
'space-missions',
|
||||
|
||||
@@ -2,13 +2,48 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js';
|
||||
import {
|
||||
shouldExpandGlobalContextPanel,
|
||||
shouldHideCollapsedRightPanels,
|
||||
} from './rightRailPolicy.js';
|
||||
|
||||
test('the desktop right rail hides collapsed panels behind the tab strip and marks them aria-hidden', () => {
|
||||
test('Tactical HUD hides collapsed right-rail siblings while one panel is expanded', () => {
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'tactical',
|
||||
hasExpandedPanel: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('collapsed launchers remain when Tactical has no expanded panel', () => {
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'tactical',
|
||||
hasExpandedPanel: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('other HUD layouts keep collapsed right-rail launchers visible', () => {
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'minimal',
|
||||
hasExpandedPanel: true,
|
||||
}), false);
|
||||
assert.equal(shouldHideCollapsedRightPanels({
|
||||
hudVariant: 'full',
|
||||
hasExpandedPanel: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
|
||||
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
assert.match(ui, /const isMobile = windowRef\.matchMedia\(MOBILE_SHELL_MEDIA_QUERY\)\.matches/);
|
||||
assert.match(ui, /const exclusive = !isMobile;/);
|
||||
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
|
||||
assert.match(
|
||||
ui,
|
||||
/!panel\.classList\.contains\('collapsed'\)\s*&&\s*\(!isMobile \|\| panel\.id !== 'pp-toggles'\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ui,
|
||||
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
||||
);
|
||||
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', ()
|
||||
assert.doesNotMatch(html, /sl-mark|mly-mark/);
|
||||
assert.match(
|
||||
html,
|
||||
/<div class="street-level-panel-inner" data-rail-scroller data-rail-card>/,
|
||||
/<div class="street-level-panel-inner" data-rail-scroller>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Just enough DOM for the rail and strip tests: a tree with classes,
|
||||
* attributes, inline style properties, boxes and events. There is no layout:
|
||||
* a test sets the offsets, rects and computed values it needs.
|
||||
*/
|
||||
export function makeDocument() {
|
||||
const doc = { activeElement: null };
|
||||
const descendants = (node) =>
|
||||
node.children.flatMap((child) => [child, ...descendants(child)]);
|
||||
const findIn = (node, predicate) => {
|
||||
for (const child of node.children) {
|
||||
if (predicate(child)) return child;
|
||||
const nested = findIn(child, predicate);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
doc.createElement = (tag) => {
|
||||
const el = new EventTarget();
|
||||
const attrs = new Map();
|
||||
const classes = new Set();
|
||||
const props = new Map();
|
||||
const writes = [];
|
||||
Object.assign(el, {
|
||||
tagName: tag.toUpperCase(),
|
||||
ownerDocument: doc,
|
||||
children: [],
|
||||
parentElement: null,
|
||||
dataset: {},
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
scrollTop: 0,
|
||||
rect: { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 },
|
||||
computed: { display: 'block', visibility: 'visible', opacity: '1' },
|
||||
// Every inline style write, in order, for write-if-changed assertions.
|
||||
writes,
|
||||
style: {
|
||||
props,
|
||||
setProperty(key, value) {
|
||||
writes.push(['set', key, value]);
|
||||
props.set(key, value);
|
||||
},
|
||||
removeProperty(key) {
|
||||
writes.push(['remove', key]);
|
||||
props.delete(key);
|
||||
},
|
||||
getPropertyValue: (key) => props.get(key) || '',
|
||||
},
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
tabIndex: -1,
|
||||
title: '',
|
||||
type: '',
|
||||
id: '',
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
toggle(name, on) {
|
||||
if (on) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
},
|
||||
setAttribute: (key, value) => attrs.set(key, String(value)),
|
||||
getAttribute: (key) => attrs.get(key) ?? null,
|
||||
removeAttribute: (key) => attrs.delete(key),
|
||||
getBoundingClientRect: () => el.rect,
|
||||
contains(target) {
|
||||
return (
|
||||
target === el || el.children.some((child) => child.contains(target))
|
||||
);
|
||||
},
|
||||
insertBefore(child, reference) {
|
||||
child.remove();
|
||||
const index = reference ? el.children.indexOf(reference) : -1;
|
||||
el.children.splice(index < 0 ? el.children.length : index, 0, child);
|
||||
child.parentElement = el;
|
||||
return child;
|
||||
},
|
||||
append(...nodes) {
|
||||
for (const node of nodes) el.insertBefore(node, null);
|
||||
},
|
||||
prepend(...nodes) {
|
||||
for (const node of nodes.reverse())
|
||||
el.insertBefore(node, el.children[0] ?? null);
|
||||
},
|
||||
remove() {
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return;
|
||||
parent.children.splice(parent.children.indexOf(el), 1);
|
||||
el.parentElement = null;
|
||||
},
|
||||
matches(selector) {
|
||||
if (selector === '[data-panel-id]') return 'panelId' in el.dataset;
|
||||
if (selector === '[data-rail-tab]') return 'railTab' in el.dataset;
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
return el.matches(selector)
|
||||
? el
|
||||
: (el.parentElement?.closest(selector) ?? null);
|
||||
},
|
||||
/** Class selectors only, comma-separated. */
|
||||
querySelector(selector) {
|
||||
const wanted = selector.split(',').map((part) => part.trim().slice(1));
|
||||
return findIn(el, (node) =>
|
||||
wanted.some((name) => node.classList.contains(name)),
|
||||
);
|
||||
},
|
||||
/** A simple selector, or one scoped to the children with ':scope >'. */
|
||||
querySelectorAll(selector) {
|
||||
const own = selector.startsWith(':scope > ');
|
||||
const simple = own ? selector.slice(':scope > '.length) : selector;
|
||||
const pool = own ? el.children : descendants(el);
|
||||
return pool.filter((node) => node.matches(simple));
|
||||
},
|
||||
focus() {
|
||||
doc.activeElement = el;
|
||||
},
|
||||
});
|
||||
Object.defineProperties(el, {
|
||||
className: {
|
||||
get: () => [...classes].join(' '),
|
||||
set(value) {
|
||||
classes.clear();
|
||||
for (const name of String(value).split(/\s+/).filter(Boolean))
|
||||
classes.add(name);
|
||||
},
|
||||
},
|
||||
firstElementChild: { get: () => el.children[0] ?? null },
|
||||
nextElementSibling: {
|
||||
get: () => {
|
||||
const siblings = el.parentElement?.children;
|
||||
if (!siblings) return null;
|
||||
return siblings[siblings.indexOf(el) + 1] ?? null;
|
||||
},
|
||||
},
|
||||
});
|
||||
return el;
|
||||
};
|
||||
doc.createElementNS = (_namespace, tag) => doc.createElement(tag);
|
||||
doc.body = doc.createElement('body');
|
||||
doc.getElementById = (id) => findIn(doc.body, (node) => node.id === id);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Dispatch a bubbling event with `target` fixed to `el`. */
|
||||
export function fire(el, type, props = {}) {
|
||||
const event = Object.assign(new Event(type, { cancelable: true }), props);
|
||||
Object.defineProperty(event, 'target', { value: el });
|
||||
for (let node = el; node; node = node.parentElement)
|
||||
node.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
} from './shareFollow.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
import { LAYER_RESTORE_ORIGINS } from '../data/layerState.js';
|
||||
|
||||
import {
|
||||
describeUnsavedWork,
|
||||
@@ -1208,14 +1207,6 @@ export class StyleManager extends ShellFacade {
|
||||
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||
showToast: (message) => this._showToast(message),
|
||||
syncViewport: () => this._syncCctvPanelViewport(),
|
||||
// Tells the panel whether a switch-on restores saved or shared state.
|
||||
subscribeEnableRequests: (listener) =>
|
||||
this._dataManager?.subscribeVisibilityRequests?.((change) => {
|
||||
if (change?.layerId === 'cctv' && change.enabled)
|
||||
listener(
|
||||
Object.values(LAYER_RESTORE_ORIGINS).includes(change.origin),
|
||||
);
|
||||
}) || null,
|
||||
setSplitFlapText,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,8 +31,6 @@ export class CctvControls {
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._lastCctvSummaryText = '';
|
||||
this._lastSeenCctvActiveId = null;
|
||||
this._cctvRestoredEnable = false;
|
||||
this._unsubscribeEnableRequests = null;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = false;
|
||||
this._cctvFrameRequestToken = 0;
|
||||
@@ -67,11 +65,6 @@ export class CctvControls {
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
if (this.destroyed) return;
|
||||
this._unsubscribeEnableRequests?.();
|
||||
this._unsubscribeEnableRequests =
|
||||
this.actions.subscribeEnableRequests?.((restored) => {
|
||||
this._cctvRestoredEnable = restored;
|
||||
}) || null;
|
||||
this._cctvUnsubscribe = this.cctv.subscribe?.((state) =>
|
||||
this._renderCctvState(state),
|
||||
);
|
||||
@@ -125,8 +118,6 @@ export class CctvControls {
|
||||
this.listeners.abort();
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._unsubscribeEnableRequests?.();
|
||||
this._unsubscribeEnableRequests = null;
|
||||
this._calibrationEdit?.(false);
|
||||
this._clearCctvFrame();
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
|
||||
@@ -221,23 +221,3 @@ test('disposing during camera enable prevents the delayed focus and future click
|
||||
assert.equal(enables, 1);
|
||||
assert.equal(focuses, 0);
|
||||
});
|
||||
|
||||
test('a restored camera layer leaves its panel to the restored panel state', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
let request;
|
||||
const opened = [];
|
||||
controls.actions.subscribeEnableRequests = (listener) => {
|
||||
request = listener;
|
||||
return () => {};
|
||||
};
|
||||
controls.actions.setPanelCollapsed = (...args) => opened.push(args);
|
||||
controls.actions.syncViewport = () => {};
|
||||
controls.connect();
|
||||
|
||||
request(true);
|
||||
controls._renderCctvState({ enabled: true, activeCameraId: 'a' });
|
||||
assert.deepEqual(opened, []);
|
||||
|
||||
controls._renderCctvState({ enabled: true, activeCameraId: 'b' });
|
||||
assert.deepEqual(opened, [['cctv-panel', false, { explicit: false }]]);
|
||||
});
|
||||
@@ -58,16 +58,12 @@ export function _renderCctvState(state) {
|
||||
if (
|
||||
effectiveActiveId &&
|
||||
effectiveActiveId !== this._lastSeenCctvActiveId &&
|
||||
(!state?.autoHop || isFirstActivation) &&
|
||||
// A restored layer leaves the panel to the restored panel state, so its
|
||||
// first camera does not take the rail from the restored tab.
|
||||
!this._cctvRestoredEnable
|
||||
(!state?.autoHop || isFirstActivation)
|
||||
) {
|
||||
this.actions.setPanelCollapsed('cctv-panel', false, {
|
||||
explicit: Boolean(state?.explicitSelection),
|
||||
});
|
||||
}
|
||||
if (effectiveActiveId) this._cctvRestoredEnable = false;
|
||||
this._lastSeenCctvActiveId = effectiveActiveId;
|
||||
|
||||
this._updateCctvSyncChip(state?.loading, enabled);
|
||||
|
||||
@@ -41,21 +41,6 @@ function fixture(theme = 'cyber') {
|
||||
};
|
||||
}
|
||||
|
||||
test('any live opening of a rail panel makes it the preferred owner; restores do not', () => {
|
||||
const f = fixture('tactical'), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, {});
|
||||
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'cctv-panel', 'a camera click opens CCTV without the explicit flag');
|
||||
assert.equal(f.display.classList.contains('collapsed'), true, 'peers are left to the rail pass, not persisted');
|
||||
f.owner.setPanelCollapsed('radio-panel', false, {});
|
||||
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a radio pick owns the rail through Context');
|
||||
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, { restore: true, persist: false });
|
||||
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a restore never claims ownership');
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
|
||||
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
|
||||
const f = fixture(), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
@@ -66,9 +51,9 @@ test('explicit Cyber opening collapses peers and claims their restoration lanes'
|
||||
assert.equal(f.context.classList.contains('collapsed'), true);
|
||||
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
|
||||
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
|
||||
f.cctv.classList.add('rail-owner-collapsed');
|
||||
f.cctv.classList.add('cyber-accordion-collapsed');
|
||||
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
|
||||
assert.equal(f.cctv.classList.contains('rail-owner-collapsed'), false);
|
||||
assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false);
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
|
||||
@@ -86,25 +71,17 @@ test('opening already-expanded Radio reveals Context and closes Display', () =>
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
|
||||
test('every theme collapses rail peers on an explicit open and keeps restored preferences', () => {
|
||||
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
|
||||
for (const theme of ['tactical', 'minimal', 'cyber']) {
|
||||
const prior = globalThis.document;
|
||||
const explicit = fixture(theme);
|
||||
globalThis.document = explicit.doc;
|
||||
const f = fixture(theme), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
explicit.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
||||
explicit.owner.setPanelCollapsed('cctv-panel', false, { explicit: true });
|
||||
assert.equal(explicit.display.classList.contains('collapsed'), true, theme);
|
||||
assert.equal(explicit.cctv.classList.contains('collapsed'), false, theme);
|
||||
} finally { globalThis.document = prior; }
|
||||
const restored = fixture(theme);
|
||||
globalThis.document = restored.doc;
|
||||
try {
|
||||
restored.owner.setPanelCollapsed('pp-toggles', false, { restore: true, persist: false });
|
||||
restored.owner.setPanelCollapsed('cctv-panel', false, { restore: true, persist: false });
|
||||
assert.equal(restored.display.classList.contains('collapsed'), false, theme);
|
||||
assert.equal(restored.cctv.classList.contains('collapsed'), false, theme);
|
||||
assert.deepEqual(restored.saved, [], theme);
|
||||
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, options);
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, options);
|
||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
||||
assert.equal(f.cctv.classList.contains('collapsed'), false);
|
||||
if (theme === 'cyber') assert.deepEqual(f.saved, []);
|
||||
} finally { globalThis.document = prior; }
|
||||
}
|
||||
});
|
||||
@@ -748,7 +748,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
);
|
||||
assert.match(
|
||||
rightRail,
|
||||
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?layout = resolve\(alignedTop\);/,
|
||||
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -13,12 +13,9 @@
|
||||
|
||||
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import {
|
||||
MOBILE_SHELL_CLASS,
|
||||
MOBILE_SHELL_MEDIA_QUERY,
|
||||
} from './mobileShellContract.js';
|
||||
|
||||
export { MOBILE_SHELL_CLASS, MOBILE_SHELL_MEDIA_QUERY };
|
||||
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
|
||||
export const MOBILE_SHELL_CLASS = 'mobile-shell';
|
||||
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
|
||||
|
||||
/**
|
||||
@@ -211,7 +208,7 @@ export function initMobileShell({
|
||||
return (
|
||||
classes.contains('collapsed') &&
|
||||
!classes.contains('layout-auto-collapsed') &&
|
||||
!classes.contains('rail-owner-collapsed')
|
||||
!classes.contains('cyber-accordion-collapsed')
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Where the phone shell begins (mobileShell.js), for the rails and panels
|
||||
* that present differently under it. Kept apart from the shell so the rail
|
||||
* layout and panel packages need none of the shell's dependencies.
|
||||
*/
|
||||
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
|
||||
export const MOBILE_SHELL_CLASS = 'mobile-shell';
|
||||
+31
-30
@@ -8,7 +8,6 @@ import {
|
||||
collapsePanelOnEscape,
|
||||
createHoverDisclosure,
|
||||
} from './panelDisclosure.js';
|
||||
import { createRightRailTabs } from './rightRailTabs.js';
|
||||
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||
{ id: 'control-panel', pinnable: true },
|
||||
{ id: 'location-bar', pinnable: true },
|
||||
@@ -102,16 +101,6 @@ export class PanelChrome {
|
||||
for (const control of this._panelDisclosureControls || [])
|
||||
control.destroy();
|
||||
this._panelDisclosureControls = [];
|
||||
// The right rail's launchers are a vertical tab strip; the tabs ask this
|
||||
// owner for every change so persistence and share links see one path.
|
||||
this._rightRailTabs?.destroy();
|
||||
this._rightRailTabs = this._rightPanelStack
|
||||
? createRightRailTabs({
|
||||
rail: this._rightPanelStack,
|
||||
setPanelCollapsed: (panelId, collapsed, options) =>
|
||||
this.setPanelCollapsed(panelId, collapsed, options),
|
||||
})
|
||||
: null;
|
||||
const targets = new Map();
|
||||
document
|
||||
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
|
||||
@@ -369,13 +358,13 @@ export class PanelChrome {
|
||||
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
||||
const panelEl = document.getElementById(spec.id);
|
||||
if (!panelEl) continue;
|
||||
// The left rail's responsive auto-collapse is presentation only; the
|
||||
// recipient restores the user's expanded preference at its own viewport.
|
||||
// A right-rail peer hidden behind the owner travels as collapsed, so the
|
||||
// recipient opens the panel the sender sees.
|
||||
const collapsed = panelEl.classList.contains('layout-auto-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
// Responsive auto-collapse is presentation only; the recipient should
|
||||
// restore the user's explicit expanded preference at its own viewport.
|
||||
const collapsed =
|
||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
||||
panelEl.classList.contains('cyber-accordion-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
// A panel the phone shell only borrowed for a sheet shares its own state.
|
||||
const entry = {
|
||||
id: spec.id,
|
||||
@@ -431,15 +420,14 @@ export class PanelChrome {
|
||||
const nextCollapsed = Boolean(collapsed);
|
||||
const wasAutoCollapsed =
|
||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
||||
panelEl.classList.contains('rail-owner-collapsed');
|
||||
// The right rail is a set of vertical tabs: a user opening one panel owns
|
||||
// the rail, including peers that were only presentation-collapsed during
|
||||
// a saved-state restore.
|
||||
panelEl.classList.contains('cyber-accordion-collapsed');
|
||||
// A user opening a Cyber rail panel owns the whole accordion, including
|
||||
// peers that were only presentation-collapsed during a saved-state restore.
|
||||
if (
|
||||
explicit &&
|
||||
!restore &&
|
||||
!nextCollapsed &&
|
||||
this._rightPanelStack &&
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
panelEl.parentElement === this._rightPanelStack &&
|
||||
// Phone sheets show several rail panels at once.
|
||||
!document.body?.classList?.contains('mobile-shell')
|
||||
@@ -479,10 +467,7 @@ export class PanelChrome {
|
||||
) {
|
||||
this._panelLayout._leftStackPreferredPanelId = null;
|
||||
}
|
||||
// Any live opening of a rail panel makes it the owner, including the
|
||||
// ones a camera click, Street Level or a radio pick request without the
|
||||
// explicit flag: the rail's one-owner pass would otherwise hide it again.
|
||||
if (!restore && !nextCollapsed && rightOwnerPanel) {
|
||||
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
|
||||
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
|
||||
} else if (
|
||||
explicit &&
|
||||
@@ -495,6 +480,7 @@ export class PanelChrome {
|
||||
// Reveal Radio's owner before the no-op check: the nested section may
|
||||
// already be expanded while its Context parent is closed.
|
||||
if (
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
document
|
||||
@@ -521,7 +507,10 @@ export class PanelChrome {
|
||||
}
|
||||
return;
|
||||
}
|
||||
panelEl.classList.remove('layout-auto-collapsed', 'rail-owner-collapsed');
|
||||
panelEl.classList.remove(
|
||||
'layout-auto-collapsed',
|
||||
'cyber-accordion-collapsed',
|
||||
);
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
@@ -533,6 +522,20 @@ export class PanelChrome {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
document.documentElement?.dataset.uiTheme !== 'cyber' &&
|
||||
document
|
||||
.getElementById('global-context-panel')
|
||||
?.classList.contains('collapsed')
|
||||
) {
|
||||
this.setPanelCollapsed('global-context-panel', false, {
|
||||
restore,
|
||||
persist,
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
||||
const otherPanel = document.getElementById('control-panel');
|
||||
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||
@@ -623,8 +626,6 @@ export class PanelChrome {
|
||||
this._disposed = true;
|
||||
this._lifetime.destroy();
|
||||
this._panelLayout.destroy();
|
||||
this._rightRailTabs?.destroy();
|
||||
this._rightRailTabs = null;
|
||||
for (const control of this._panelDisclosureControls || [])
|
||||
control.destroy();
|
||||
this._panelDisclosureControls = [];
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
|
||||
function classList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
contains: (value) => values.has(value),
|
||||
add: (...items) => items.forEach((item) => values.add(item)),
|
||||
remove: (...items) => items.forEach((item) => values.delete(item)),
|
||||
toggle(value, force) {
|
||||
if (force === true) values.add(value);
|
||||
else if (force === false) values.delete(value);
|
||||
else if (values.has(value)) values.delete(value);
|
||||
else values.add(value);
|
||||
return values.has(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A globe station pick opens Radio, which may already be expanded inside a
|
||||
// closed Context.
|
||||
for (const theme of ['tactical', 'cyber']) {
|
||||
test(`${theme}: a radio pick reopens the closed Context around an expanded Radio section`, (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed']),
|
||||
};
|
||||
const panels = {
|
||||
'global-context-panel': contextPanel,
|
||||
'radio-panel': { id: 'radio-panel', classList: classList() },
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: { uiTheme: theme } },
|
||||
body: { classList: classList() },
|
||||
getElementById: (id) => panels[id] || null,
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
});
|
||||
const owner = {
|
||||
_leftPanelStack: null,
|
||||
_rightPanelStack: { contains: (el) => el === contextPanel },
|
||||
_panelLayout: {
|
||||
_leftStackPreferredPanelId: null,
|
||||
_rightStackPreferredPanelId: 'pp-toggles',
|
||||
},
|
||||
_syncPanelCollapseButton() {},
|
||||
_scheduleLeftPanelLayout() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
_savePanelCollapsedState() {},
|
||||
_syncCctvPanelViewport() {},
|
||||
_updateCommandDockTrayStack() {},
|
||||
_lifetime: { frame() {} },
|
||||
shareLinkManager: null,
|
||||
cockpitView: null,
|
||||
};
|
||||
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed;
|
||||
|
||||
owner.setPanelCollapsed('radio-panel', false);
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
});
|
||||
}
|
||||
@@ -62,14 +62,9 @@ export function collapsePanelOnEscape(
|
||||
const disclosure =
|
||||
panel.querySelector(`[data-dock-toggle-target="${panel.id}"]`) ||
|
||||
panel.querySelector(`[data-collapse-target="${panel.id}"]`);
|
||||
// A right-rail panel collapses out of view; its tab stays and takes focus.
|
||||
const tab = panel.ownerDocument?.querySelector?.(
|
||||
`[role="tab"][aria-controls="${panel.id}"]`,
|
||||
);
|
||||
const escapedFromDisclosure =
|
||||
event.target === disclosure || disclosure?.contains?.(event.target);
|
||||
if (tab) tab.focus({ preventScroll: true });
|
||||
else if (escapedFromDisclosure) disclosure?.blur?.();
|
||||
if (escapedFromDisclosure) disclosure?.blur?.();
|
||||
else disclosure?.focus?.({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -299,8 +299,10 @@ export class PanelLayoutController {
|
||||
stack: this._rightPanelStack,
|
||||
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
|
||||
windowRef: window,
|
||||
hud: this.readHud(),
|
||||
preferredPanelId: this._rightStackPreferredPanelId,
|
||||
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
|
||||
onRetry: () => this._scheduleRightPanelLayout(),
|
||||
leftStack: this._leftPanelStack,
|
||||
displayPanel: this._ppToggles,
|
||||
readDisplayScrollTop: this.readDisplayScrollTop,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Own panel collapsed-state preferences and the one-time layout-reset notice. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { MOBILE_SHELL_MEDIA_QUERY } from './mobileShellContract.js';
|
||||
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||
/**
|
||||
@@ -67,15 +66,15 @@ export class PanelPositionControls {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
// DISPLAY starts EXPANDED for a first-time visitor on a desktop viewport:
|
||||
// the right rail is a strip of label tabs (rightRailTabs.js), and with
|
||||
// every panel closed nothing shows that the labels are launchers. Phones
|
||||
// keep it collapsed (their sheets present the rail); a share link decides
|
||||
// for itself. A stored choice wins in both directions.
|
||||
if (panelId === 'pp-toggles' && stored === null)
|
||||
collapsed =
|
||||
!allowStored ||
|
||||
globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches === true;
|
||||
// DISPLAY starts COLLAPSED for a first-time visitor, then respects the
|
||||
// user's persisted choice like every other panel.
|
||||
//
|
||||
// It used to start expanded, to advertise the HUD / DETECT / 3D toggles.
|
||||
// That reason expired when those became ON by default: the rail now opens
|
||||
// to offer controls for things already happening, while competing with the
|
||||
// first-run mission card for the one first impression there is. A stored
|
||||
// choice still wins in both directions, so anyone who opens it keeps it.
|
||||
if (panelId === 'pp-toggles' && stored === null) collapsed = true;
|
||||
panelEl.classList.toggle('collapsed', collapsed);
|
||||
// Bodies that expand themselves on first appearance must not override a
|
||||
// choice the user (or a share link) already made.
|
||||
|
||||
@@ -60,3 +60,19 @@ export function resolveHudRailLayout({
|
||||
constrained: panelHeight > availableHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tactical HUD gives an expanded right-rail panel the whole control lane.
|
||||
* Other HUD layouts keep collapsed launchers visible for quick switching.
|
||||
*
|
||||
* @param {object} input Current rail state.
|
||||
* @param {string} input.hudVariant Active HUD layout variant.
|
||||
* @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded.
|
||||
* @returns {boolean} Whether collapsed sibling launchers should be hidden.
|
||||
*/
|
||||
export function shouldHideCollapsedRightPanels({
|
||||
hudVariant,
|
||||
hasExpandedPanel,
|
||||
}) {
|
||||
return hudVariant === 'tactical' && Boolean(hasExpandedPanel);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
export { layoutLeftPanelRail } from './leftPanelRail.js';
|
||||
export { layoutRightPanelRail } from './rightPanelRail.js';
|
||||
export { measurePanelNaturalHeight } from './panelMeasurement.js';
|
||||
export { resolveHudRailLayout } from './panelRailGeometry.js';
|
||||
export {
|
||||
resolveHudRailLayout,
|
||||
shouldHideCollapsedRightPanels,
|
||||
} from './panelRailGeometry.js';
|
||||
export {
|
||||
allocatePanelStackHeights,
|
||||
panelStackAutoCollapseIndices,
|
||||
|
||||
+146
-167
@@ -1,4 +1,3 @@
|
||||
import { makeDocument } from '../testSupport/fakeDom.mjs';
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -21,30 +20,33 @@ test('Display uses an inner scroll body only in Cyber', () => {
|
||||
assert.equal(displayPanelScroller(null), null);
|
||||
});
|
||||
|
||||
test('every HUD layout keeps one expanded owner, hides its peers and never reopens them', () => {
|
||||
for (const variant of ['tactical', 'minimal', 'cyber']) {
|
||||
const f = fixture('right', { hud: { visible: true, variant } });
|
||||
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
|
||||
for (const mobile of [false]) {
|
||||
const f = fixture('right', {
|
||||
mobile,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
});
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.preferredPanelId = f.second.id;
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.first.classList.contains('rail-owner-collapsed'),
|
||||
true,
|
||||
variant,
|
||||
);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false, variant);
|
||||
assert.equal(
|
||||
f.first.getAttribute('aria-hidden'),
|
||||
'true',
|
||||
`${variant}: a collapsed peer hides behind the tab strip`,
|
||||
);
|
||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.first.classList.contains('collapsed'),
|
||||
true,
|
||||
`${variant}: layout does not reopen a peer`,
|
||||
'layout does not reopen a peer',
|
||||
);
|
||||
f.options.hud.variant = 'minimal';
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(
|
||||
f.first.classList.contains('cyber-accordion-collapsed'),
|
||||
false,
|
||||
);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,7 +62,7 @@ test('Cyber keeps no accordion on phones, where the rail is presented as tab she
|
||||
f.options.preferredPanelId = f.second.id;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('rail-owner-collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
@@ -74,26 +76,73 @@ test('Cyber restored accordion prefers keyboard focus when no explicit owner exi
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
const doc = makeDocument();
|
||||
/** A rail panel (or any rail-side box) with its measured box and no content. */
|
||||
function element(
|
||||
id,
|
||||
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
|
||||
) {
|
||||
const node = doc.createElement('div');
|
||||
node.id = id;
|
||||
node.dataset.panelId = id;
|
||||
if (collapsed) node.classList.add('collapsed');
|
||||
node.scrollHeight = node.clientHeight = height;
|
||||
node.computed.rowGap = '12px';
|
||||
Object.assign(node.rect, {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
});
|
||||
const classes = new Set(collapsed ? ['collapsed'] : []);
|
||||
const properties = new Map();
|
||||
const attributes = new Map();
|
||||
const writes = [];
|
||||
const node = {
|
||||
id,
|
||||
children: [],
|
||||
dataset: {},
|
||||
parentElement: null,
|
||||
scrollHeight: height,
|
||||
clientHeight: height,
|
||||
scrollTop: 0,
|
||||
computed: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
rowGap: '12px',
|
||||
},
|
||||
rect: {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
},
|
||||
classList: {
|
||||
contains: (name) => classes.has(name),
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
toggle(name, value) {
|
||||
if (value) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
},
|
||||
style: {
|
||||
setProperty(name, value) {
|
||||
writes.push(['set', name, value]);
|
||||
properties.set(name, value);
|
||||
},
|
||||
getPropertyValue: (name) => properties.get(name) || '',
|
||||
removeProperty(name) {
|
||||
writes.push(['remove', name]);
|
||||
properties.delete(name);
|
||||
},
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return this.rect;
|
||||
},
|
||||
matches: (selector) => selector === '[data-panel-id]',
|
||||
contains(target) {
|
||||
return (
|
||||
target === this || this.children.some((child) => child.contains(target))
|
||||
);
|
||||
},
|
||||
querySelectorAll() {
|
||||
return this.children;
|
||||
},
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
removeAttribute: (name) => attributes.delete(name),
|
||||
getAttribute: (name) => attributes.get(name),
|
||||
writes,
|
||||
};
|
||||
return node;
|
||||
}
|
||||
function fixture(
|
||||
@@ -199,66 +248,36 @@ for (const side of ['left', 'right']) {
|
||||
f.first.style.getPropertyValue(`--${side}-panel-allocated-height`),
|
||||
'',
|
||||
);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), null);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
});
|
||||
test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => {
|
||||
const f = fixture(side);
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
f.options.preferredPanelId = 'second';
|
||||
f.run();
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
|
||||
assert.deepEqual(f.collapsed, ['first']);
|
||||
assert.equal(f.retries(), 1);
|
||||
});
|
||||
test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => {
|
||||
const f = fixture(side, { hud: { visible: false, variant: 'tactical' } });
|
||||
f.first.classList.add('layout-auto-collapsed');
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), true);
|
||||
assert.deepEqual(f.collapsed, ['first']);
|
||||
});
|
||||
}
|
||||
|
||||
test('left constrained layout preserves the preferred panel and requests another pass', () => {
|
||||
const f = fixture('left');
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
f.options.preferredPanelId = 'second';
|
||||
f.run();
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
|
||||
assert.deepEqual(f.collapsed, ['first']);
|
||||
assert.equal(f.retries(), 1);
|
||||
});
|
||||
|
||||
test('left hidden HUD restores automatic collapse without altering manual collapse', () => {
|
||||
const f = fixture('left', { hud: { visible: false, variant: 'tactical' } });
|
||||
f.first.classList.add('layout-auto-collapsed');
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), true);
|
||||
assert.deepEqual(f.collapsed, ['first']);
|
||||
});
|
||||
|
||||
test('right constrained layout keeps the preferred owner and hides its peer in one pass', () => {
|
||||
const f = fixture('right');
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
f.options.preferredPanelId = 'second';
|
||||
f.run();
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('rail-owner-collapsed'), true);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), 'true');
|
||||
assert.deepEqual(f.collapsed, ['first']);
|
||||
assert.equal(f.retries(), 0, 'one owner needs no second pass');
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
||||
});
|
||||
|
||||
test('right layout uses keyboard focus when there is no preferred panel', () => {
|
||||
const f = fixture('right');
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
f.options.documentRef.activeElement = f.second;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('rail-owner-collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
test('right layout lets a stored or shared choice outrank a panel open only by default', () => {
|
||||
// Display opens by default on a first desktop visit; a visitor who had
|
||||
// opened CCTV keeps CCTV as the owner, whatever the rail order.
|
||||
const f = fixture('right');
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
f.first.dataset.collapsedPreference = 'default';
|
||||
f.second.dataset.collapsedPreference = 'stored';
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('rail-owner-collapsed'), true);
|
||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
@@ -329,35 +348,6 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
|
||||
assert.equal(f.stack.dataset.safeTop, '200.0');
|
||||
});
|
||||
|
||||
test('right rail climbs above the left rail baseline when its tab strip needs the room, no further than the HUD above', () => {
|
||||
const f = fixture('right');
|
||||
f.options.leftStack.rect.top = 300;
|
||||
const above = element('above', { left: 1100, top: 100, height: 50 });
|
||||
const below = element('below', { left: 1100, top: 600, height: 50 });
|
||||
f.options.obstacles = [above, below];
|
||||
const column = element('column', { height: 380 });
|
||||
f.stack.querySelector = (selector) =>
|
||||
selector.includes('#right-rail-column') ? column : null;
|
||||
// The gap is 10.8 at 900px: the lane 300..589.2 is 90.8 short of the column.
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.safeTop, '209.2');
|
||||
assert.equal(f.stack.dataset.safeBottom, '589.2');
|
||||
column.rect.height = 600;
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.stack.dataset.safeTop,
|
||||
'160.8',
|
||||
'the HUD corner above stops the climb',
|
||||
);
|
||||
column.rect.height = 200;
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.stack.dataset.safeTop,
|
||||
'300.0',
|
||||
'a column that fits leaves the baseline alone',
|
||||
);
|
||||
});
|
||||
|
||||
// The left rail's `top` animates after the right pass runs, and nothing
|
||||
// re-runs it when the transition lands: the right rail must read the end value.
|
||||
function animatingLeftRail({ keyframes = true } = {}) {
|
||||
@@ -448,7 +438,8 @@ function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
|
||||
f.options.preferredPanelId = cctv.id;
|
||||
f.options.displayPanel = display;
|
||||
const allocation = '--right-panel-allocated-height';
|
||||
const measuring = () => f.stack.getAttribute('data-rail-measuring') !== null;
|
||||
const measuring = () =>
|
||||
f.stack.getAttribute('data-rail-measuring') !== undefined;
|
||||
for (const panel of f.stack.children) {
|
||||
panel.parentElement = f.stack;
|
||||
panel.intrinsicHeight = panel.rect.height;
|
||||
@@ -497,11 +488,10 @@ for (const focused of [false, true]) {
|
||||
modes.push(f.stack.classList.contains('layout-focus'));
|
||||
needs.push(f.stack.dataset.requiredHeight);
|
||||
assert.equal(f.stack.dataset.availableHeight, '443.6');
|
||||
assert.equal(f.stack.getAttribute('data-rail-measuring'), null);
|
||||
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
|
||||
}
|
||||
assert.deepEqual(modes.slice(1), Array(9).fill(true));
|
||||
// A collapsed peer is hidden behind the tab strip and takes no room.
|
||||
assert.deepEqual(needs, Array(10).fill('629.0'));
|
||||
assert.deepEqual(needs, Array(10).fill('681.0'));
|
||||
assert.equal(f.retries(), 0);
|
||||
assert.equal(f.cctv.writes.filter(([op]) => op === 'remove').length, 0);
|
||||
});
|
||||
@@ -510,16 +500,16 @@ for (const focused of [false, true]) {
|
||||
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
|
||||
const f = thrashingRightRail();
|
||||
f.run();
|
||||
// The owner is the whole need: it moves 420 -> 450 -> 420.
|
||||
for (const height of [420, 450, 420, 450, 420]) {
|
||||
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
|
||||
for (const height of [368, 398, 368, 398, 368]) {
|
||||
f.cctv.intrinsicHeight = height;
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
||||
}
|
||||
f.cctv.intrinsicHeight = 387; // Need 387 < 443.6 - 55.2.
|
||||
f.cctv.intrinsicHeight = 335; // Need 387 < 443.6 - 55.2.
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||||
f.cctv.intrinsicHeight = 417;
|
||||
f.cctv.intrinsicHeight = 365;
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||||
});
|
||||
@@ -528,44 +518,40 @@ test('right rail enters focus only above available height and leaves below the f
|
||||
const f = thrashingRightRail();
|
||||
f.options.windowRef.innerHeight = 1000;
|
||||
f.options.leftStack.rect.top = 500;
|
||||
f.cctv.intrinsicHeight = 460; // Need equals available (460 px).
|
||||
f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||||
f.cctv.intrinsicHeight = 461;
|
||||
f.cctv.intrinsicHeight = 409;
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
||||
f.cctv.intrinsicHeight = 400; // Need equals the 400 px exit boundary.
|
||||
f.cctv.intrinsicHeight = 348; // Need equals the 400 px exit boundary.
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), true);
|
||||
f.cctv.intrinsicHeight = 399;
|
||||
f.cctv.intrinsicHeight = 347;
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-focus'), false);
|
||||
});
|
||||
|
||||
test('right rail with a second expanded panel settles in one pass: the owner keeps the lane', () => {
|
||||
test('right rail auto-collapse requests only one retry and settles within two passes', () => {
|
||||
const f = thrashingRightRail({ weatherOpen: true });
|
||||
const modes = [];
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
f.run();
|
||||
modes.push(f.stack.classList.contains('layout-focus'));
|
||||
}
|
||||
assert.deepEqual(
|
||||
f.collapsed,
|
||||
['weather-panel'],
|
||||
'the peer is collapsed once',
|
||||
);
|
||||
assert.equal(f.weather.classList.contains('rail-owner-collapsed'), true);
|
||||
assert.equal(f.retries(), 0);
|
||||
assert.deepEqual(f.collapsed, ['weather-panel']);
|
||||
assert.equal(f.retries(), 1);
|
||||
assert.deepEqual(modes.slice(1), Array(9).fill(true));
|
||||
// Reopening the peer makes it the owner only through an explicit preference.
|
||||
f.weather.classList.remove('collapsed', 'rail-owner-collapsed');
|
||||
});
|
||||
|
||||
test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => {
|
||||
const f = thrashingRightRail({ weatherOpen: true });
|
||||
f.run();
|
||||
assert.equal(f.weather.classList.contains('collapsed'), true);
|
||||
f.options.preferredPanelId = 'weather-panel';
|
||||
f.weather.classList.remove('collapsed', 'rail-owner-collapsed');
|
||||
assert.equal(f.retries(), 1);
|
||||
f.weather.classList.remove('collapsed', 'layout-auto-collapsed');
|
||||
f.run();
|
||||
assert.equal(f.weather.classList.contains('collapsed'), false);
|
||||
assert.equal(f.cctv.classList.contains('rail-owner-collapsed'), true);
|
||||
assert.equal(f.retries(), 1);
|
||||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
||||
});
|
||||
|
||||
test('right rail restores presentation even when intrinsic measurement throws', () => {
|
||||
@@ -574,7 +560,7 @@ test('right rail restores presentation even when intrinsic measurement throws',
|
||||
throw new Error('measurement failed');
|
||||
};
|
||||
assert.throws(() => f.run(), /measurement failed/);
|
||||
assert.equal(f.stack.getAttribute('data-rail-measuring'), null);
|
||||
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
|
||||
});
|
||||
|
||||
test('right rail measuring pass keeps an opted-in scroller position', () => {
|
||||
@@ -609,19 +595,14 @@ test('right rail measuring pass keeps an opted-in scroller position', () => {
|
||||
});
|
||||
|
||||
for (const variant of ['minimal', 'full']) {
|
||||
test(`right rail keeps one owner in the ${variant} HUD and settles without a retry`, () => {
|
||||
test(`right rail does not retry collapse that the ${variant} HUD immediately restores`, () => {
|
||||
const f = fixture('right', { hud: { visible: true, variant } });
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 900);
|
||||
for (let pass = 0; pass < 10; pass++) f.run();
|
||||
assert.equal(f.retries(), 0);
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('rail-owner-collapsed'), true);
|
||||
assert.deepEqual(
|
||||
f.collapsed,
|
||||
['second'],
|
||||
'the peer is collapsed once, not on every pass',
|
||||
);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
}
|
||||
// ── Narrow-screen rail overflow pin ──────────────────────────────────────────
|
||||
@@ -709,7 +690,7 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('right layout ignores a CSS-hidden expanded panel: it is neither owner nor laid out', () => {
|
||||
test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => {
|
||||
const f = fixture('right');
|
||||
const weather = element('weather-panel', { height: 300 });
|
||||
weather.computed.display = 'none';
|
||||
@@ -718,12 +699,10 @@ test('right layout ignores a CSS-hidden expanded panel: it is neither owner nor
|
||||
|
||||
f.run();
|
||||
|
||||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||||
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
|
||||
assert.equal(f.stack.dataset.expandedCount, '0');
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(weather.classList.contains('collapsed'), false);
|
||||
assert.equal(weather.getAttribute('aria-hidden'), null);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
|
||||
assert.equal(
|
||||
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
'',
|
||||
@@ -742,18 +721,17 @@ test('right layout still restores panels that exclusive presentation hides', ()
|
||||
: node.computed.display,
|
||||
});
|
||||
f.expand(f.first, 250);
|
||||
f.second.classList.add('rail-owner-collapsed');
|
||||
f.second.classList.add('layout-auto-collapsed');
|
||||
f.stack.classList.add('layout-exclusive');
|
||||
|
||||
f.run();
|
||||
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||||
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
|
||||
|
||||
// Phone sheets show several panels at once: the hidden peer comes back.
|
||||
f.options.windowRef.matchMedia = () => ({ matches: true });
|
||||
f.options.hud.visible = false;
|
||||
f.run();
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('rail-owner-collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.deepEqual(f.collapsed, ['second']);
|
||||
});
|
||||
|
||||
@@ -777,25 +755,26 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', (
|
||||
imagery.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
'',
|
||||
);
|
||||
assert.equal(imagery.getAttribute('aria-hidden'), null);
|
||||
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
|
||||
const alone = parseFloat(
|
||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
);
|
||||
// Shown (and expanded) it is a peer: the owner keeps the lane and the
|
||||
// newcomer is presentation-collapsed in the same pass, with no retry.
|
||||
// Shown (and expanded) it joins the allocation like any other panel.
|
||||
imagery.hidden = false;
|
||||
imagery.scrollHeight = 300;
|
||||
imagery.rect.height = 300;
|
||||
const retriesBefore = f.retries();
|
||||
f.run();
|
||||
assert.deepEqual(f.collapsed, ['recent-imagery-panel']);
|
||||
assert.equal(imagery.classList.contains('rail-owner-collapsed'), true);
|
||||
assert.equal(f.retries(), retriesBefore);
|
||||
assert.ok(
|
||||
f.collapsed.includes('recent-imagery-panel'),
|
||||
'focus mode collapses the later panel and asks for another pass',
|
||||
);
|
||||
assert.equal(f.retries(), retriesBefore + 1);
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.equal(
|
||||
assert.ok(
|
||||
parseFloat(
|
||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
),
|
||||
alone,
|
||||
) <= alone,
|
||||
);
|
||||
});
|
||||
@@ -105,29 +105,6 @@ export class RadioControls {
|
||||
top: next,
|
||||
behavior: reducedMotion ? 'auto' : 'smooth',
|
||||
});
|
||||
// A rail layout pass that measures the panels mid-scroll puts the scroll
|
||||
// offset back where it was, which cancels the smooth scroll: land it when
|
||||
// the scroll ends short, unless the user took the scroll over meanwhile.
|
||||
if (!reducedMotion) {
|
||||
const landing = new AbortController();
|
||||
const signal = AbortSignal.any([
|
||||
this.listeners.signal,
|
||||
landing.signal,
|
||||
AbortSignal.timeout(2000),
|
||||
]);
|
||||
const cancel = () => landing.abort();
|
||||
for (const type of ['keydown', 'wheel', 'pointerdown', 'touchstart'])
|
||||
scroller.addEventListener(type, cancel, { signal, passive: true });
|
||||
scroller.addEventListener(
|
||||
'scrollend',
|
||||
() => {
|
||||
cancel();
|
||||
if (Math.abs(scroller.scrollTop - next) >= 1)
|
||||
scroller.scrollTop = next;
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -171,59 +171,3 @@ test('destruction releases an active tuner pointer after revoking listeners', ()
|
||||
f.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a user scroll during the Radio reveal is not undone by the scrollend landing', async () => {
|
||||
const prior = {
|
||||
document: globalThis.document,
|
||||
window: globalThis.window,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
};
|
||||
const rect = (top, bottom) => ({ top, bottom });
|
||||
const scroller = Object.assign(new EventTarget(), {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 1200,
|
||||
clientHeight: 300,
|
||||
getBoundingClientRect: () => rect(100, 400),
|
||||
scrollTo() {},
|
||||
});
|
||||
const below = (top) => ({
|
||||
getBoundingClientRect: () =>
|
||||
rect(top - scroller.scrollTop, top + 30 - scroller.scrollTop),
|
||||
});
|
||||
const parts = {
|
||||
'.radio-directory-row': below(500),
|
||||
'.radio-transport': below(700),
|
||||
};
|
||||
const context = {
|
||||
classList: { contains: () => false },
|
||||
querySelector: (sel) =>
|
||||
sel === '.global-context-panel-inner' ? scroller : null,
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: {} },
|
||||
getElementById: (id) => (id === 'global-context-panel' ? context : null),
|
||||
};
|
||||
globalThis.window = { matchMedia: () => ({ matches: false }) };
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
const controls = Object.assign(Object.create(RadioControls.prototype), {
|
||||
destroyed: false,
|
||||
listeners: new AbortController(),
|
||||
_radioPanel: { querySelector: (sel) => parts[sel] ?? null },
|
||||
_radioState: { enabled: true },
|
||||
});
|
||||
try {
|
||||
assert.equal(
|
||||
await controls._revealRadioControlsAfterExplicitEnable({
|
||||
isConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
scroller.dispatchEvent(new Event('keydown'));
|
||||
scroller.scrollTop = 0;
|
||||
scroller.dispatchEvent(new Event('scrollend'));
|
||||
assert.equal(scroller.scrollTop, 0);
|
||||
} finally {
|
||||
controls.listeners.abort();
|
||||
Object.assign(globalThis, prior);
|
||||
}
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
+108
-54
@@ -1,8 +1,15 @@
|
||||
import { allocatePanelStackHeights } from '../panelStackLayout.js';
|
||||
import { resolveHudRailLayout } from './panelRailGeometry.js';
|
||||
import {
|
||||
allocatePanelStackHeights,
|
||||
panelStackAutoCollapseIndices,
|
||||
} from '../panelStackLayout.js';
|
||||
import {
|
||||
resolveHudRailLayout,
|
||||
shouldHideCollapsedRightPanels,
|
||||
} from './panelRailGeometry.js';
|
||||
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
import { MOBILE_SHELL_MEDIA_QUERY } from './mobileShellContract.js';
|
||||
|
||||
const pendingCollapseRetries = new WeakSet();
|
||||
|
||||
/**
|
||||
* Where the left rail's top will settle, not where it is mid-transition:
|
||||
@@ -29,17 +36,15 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
|
||||
/**
|
||||
* Measure and place the right panel rail for one synchronous layout pass.
|
||||
* The caller owns scheduling, obstacle selection, disclosure preferences and
|
||||
* persistence. The rail presents its panels as vertical tabs
|
||||
* (rightRailTabs.js): one owner stays expanded, its peers are
|
||||
* presentation-collapsed and hidden, and the owner takes the whole lane. The
|
||||
* lane's top follows the left rail's, lifted toward the HUD above when the tab
|
||||
* strip's column (rightRailTabs.js) is taller than the lane.
|
||||
* persistence. Auto-collapse is presentation only and reports through callbacks.
|
||||
* @param {object} options Live DOM and caller policy.
|
||||
* @param {HTMLElement} options.stack Rail element.
|
||||
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
|
||||
* @param {Window} options.windowRef Viewport and style reader.
|
||||
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
|
||||
* @param {string} options.preferredPanelId Most recently opened panel.
|
||||
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
|
||||
* @param {Function} options.onRetry Request another pass after automatic collapse.
|
||||
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
|
||||
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
|
||||
* @param {HTMLElement} options.displayPanel Panel whose allocation owns its scroll.
|
||||
@@ -50,8 +55,10 @@ export function layoutRightPanelRail({
|
||||
stack,
|
||||
obstacles,
|
||||
windowRef,
|
||||
hud,
|
||||
preferredPanelId,
|
||||
onCollapse,
|
||||
onRetry,
|
||||
leftStack,
|
||||
displayPanel,
|
||||
readDisplayScrollTop,
|
||||
@@ -59,6 +66,8 @@ export function layoutRightPanelRail({
|
||||
getComputedStyle = (element) => windowRef.getComputedStyle(element),
|
||||
}) {
|
||||
if (!stack) return;
|
||||
// A collapse may schedule one follow-up, which only measures and allocates.
|
||||
const isCollapseRetry = pendingCollapseRetries.delete(stack);
|
||||
|
||||
// Other CSS may hide a panel, such as an event view: it is not laid out.
|
||||
// Exclusive presentation hides collapsed panels itself; those stay in the
|
||||
@@ -72,40 +81,51 @@ export function layoutRightPanelRail({
|
||||
!panel.hidden &&
|
||||
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
|
||||
);
|
||||
// One expanded owner: the latest explicit disclosure, else the focused
|
||||
// panel, else one the visitor or a share link chose over one open only by
|
||||
// default (panelPositionControls.js marks each), else the first. Peers are
|
||||
// presentation-collapsed; the marker is distinct from the left rail's
|
||||
// space-based collapse. Phone sheets show several panels at once, so phones
|
||||
// get every panel back.
|
||||
const isMobile = windowRef.matchMedia(MOBILE_SHELL_MEDIA_QUERY).matches;
|
||||
if (!isMobile) {
|
||||
if (!hud.visible || hud.variant !== 'tactical') {
|
||||
for (const panel of panels.filter((item) =>
|
||||
item.classList.contains('layout-auto-collapsed'),
|
||||
)) {
|
||||
panel.classList.remove('collapsed', 'layout-auto-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
// Normalize restored state and theme entry without overwriting saved panel
|
||||
// preferences. This marker is distinct from Tactical's space-based collapse.
|
||||
// Phones present the rail as tab sheets that show several panels at once.
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
if (hud.variant === 'cyber' && !isMobile) {
|
||||
const expanded = panels.filter(
|
||||
(panel) => !panel.classList.contains('collapsed'),
|
||||
);
|
||||
const owner =
|
||||
expanded.find((panel) => panel.id === preferredPanelId) ||
|
||||
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
|
||||
expanded.find(
|
||||
(panel) => panel.dataset?.collapsedPreference !== 'default',
|
||||
) ||
|
||||
expanded[0];
|
||||
for (const panel of expanded) {
|
||||
if (panel === owner) continue;
|
||||
panel.classList.add('collapsed', 'rail-owner-collapsed');
|
||||
panel.classList.add('collapsed', 'cyber-accordion-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
} else {
|
||||
for (const panel of panels) {
|
||||
if (!panel.classList.contains('rail-owner-collapsed')) continue;
|
||||
panel.classList.remove('collapsed', 'rail-owner-collapsed');
|
||||
if (!panel.classList.contains('cyber-accordion-collapsed')) continue;
|
||||
panel.classList.remove('collapsed', 'cyber-accordion-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
// The tab strip is the launcher, so collapsed panels hide. Phones present
|
||||
// the rail as tab sheets (mobileShell.js), which choose what shows; hiding
|
||||
// collapsed siblings would empty a sheet and its a11y tree.
|
||||
const exclusive = !isMobile;
|
||||
const hasExpandedPanel = panels.some(
|
||||
(panel) =>
|
||||
!panel.classList.contains('collapsed') &&
|
||||
(!isMobile || panel.id !== 'pp-toggles'),
|
||||
);
|
||||
// Phones present the rail as tab sheets (mobileShell.js), which choose what
|
||||
// shows; hiding collapsed siblings would empty a sheet and its a11y tree.
|
||||
const exclusive =
|
||||
!isMobile &&
|
||||
shouldHideCollapsedRightPanels({
|
||||
hudVariant: hud.variant,
|
||||
hasExpandedPanel,
|
||||
});
|
||||
stack.classList.toggle('layout-exclusive', exclusive);
|
||||
for (const panel of panels) {
|
||||
if (exclusive && panel.classList.contains('collapsed'))
|
||||
@@ -161,8 +181,14 @@ export function layoutRightPanelRail({
|
||||
});
|
||||
}
|
||||
|
||||
// Exclusive presentation hides collapsed panels, so only visible panels
|
||||
// take room in the lane.
|
||||
// Auto-collapsed headers contribute the same need even when exclusive
|
||||
// presentation hides them. Manually collapsed launchers keep their policy.
|
||||
const measuredPanels = panels.filter(
|
||||
(panel) =>
|
||||
!exclusive ||
|
||||
!panel.classList.contains('collapsed') ||
|
||||
panel.classList.contains('layout-auto-collapsed'),
|
||||
);
|
||||
const visiblePanels = panels.filter(
|
||||
(panel) => !exclusive || !panel.classList.contains('collapsed'),
|
||||
);
|
||||
@@ -175,15 +201,12 @@ export function layoutRightPanelRail({
|
||||
.map((node) => [node, node.scrollTop || 0])
|
||||
.filter(([, top]) => top > 0);
|
||||
const naturalHeights = new Map();
|
||||
// The tab strip's column stands beside the panels at its natural height.
|
||||
const column = stack.querySelector?.(':scope > #right-rail-column');
|
||||
let columnHeight = 0;
|
||||
// Remove live height allocations through a synchronous CSS override. Keeping
|
||||
// the stored properties intact avoids REMOVE/SET churn on unchanged panels.
|
||||
// Do not toggle layout classes here: the class observer schedules new passes.
|
||||
stack.setAttribute('data-rail-measuring', '');
|
||||
try {
|
||||
for (const panel of visiblePanels) {
|
||||
for (const panel of measuredPanels) {
|
||||
naturalHeights.set(
|
||||
panel,
|
||||
Math.max(
|
||||
@@ -193,7 +216,6 @@ export function layoutRightPanelRail({
|
||||
),
|
||||
);
|
||||
}
|
||||
columnHeight = column?.getBoundingClientRect().height || 0;
|
||||
} finally {
|
||||
stack.removeAttribute('data-rail-measuring');
|
||||
for (const [node, top] of scrollers)
|
||||
@@ -201,30 +223,23 @@ export function layoutRightPanelRail({
|
||||
}
|
||||
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
|
||||
const naturalHeight =
|
||||
visiblePanels.reduce(
|
||||
measuredPanels.reduce(
|
||||
(total, panel) => total + naturalHeights.get(panel),
|
||||
0,
|
||||
) +
|
||||
gap * Math.max(0, visiblePanels.length - 1);
|
||||
const resolve = (baseTop) =>
|
||||
resolveHudRailLayout({
|
||||
viewportHeight,
|
||||
panelHeight: naturalHeight,
|
||||
laneLeft: stackRect.left,
|
||||
laneRight: stackRect.right,
|
||||
obstacles: obstacleRects,
|
||||
baseTop,
|
||||
baseBottom: viewportHeight * 0.96,
|
||||
gap: safeGap,
|
||||
align: 'start',
|
||||
});
|
||||
let layout = resolve(alignedTop);
|
||||
gap * Math.max(0, measuredPanels.length - 1);
|
||||
const layout = resolveHudRailLayout({
|
||||
viewportHeight,
|
||||
panelHeight: naturalHeight,
|
||||
laneLeft: stackRect.left,
|
||||
laneRight: stackRect.right,
|
||||
obstacles: obstacleRects,
|
||||
baseTop: alignedTop,
|
||||
baseBottom: viewportHeight * 0.96,
|
||||
gap: safeGap,
|
||||
align: 'start',
|
||||
});
|
||||
if (!layout) return;
|
||||
// A lane too short for the strip's column: the rail climbs toward the HUD
|
||||
// above, as far as the strip needs and the obstacles allow. A lane still
|
||||
// shorter leaves the strip running past the lane's end.
|
||||
const shortfall = columnHeight - (layout.safeBottom - layout.safeTop);
|
||||
if (shortfall > 0) layout = resolve(alignedTop - shortfall) || layout;
|
||||
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
|
||||
const stabilityBand = Math.max(48, viewportHeight * 0.06);
|
||||
const wasFocused = stack.classList.contains('layout-focus');
|
||||
@@ -239,9 +254,26 @@ export function layoutRightPanelRail({
|
||||
: total,
|
||||
0,
|
||||
);
|
||||
const expandedPanels = visiblePanels.filter(
|
||||
const expandedPanelsInDomOrder = visiblePanels.filter(
|
||||
(panel) => !panel.classList.contains('collapsed'),
|
||||
);
|
||||
const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) =>
|
||||
panel.contains(documentRef.activeElement),
|
||||
);
|
||||
const preferredExpandedPanel =
|
||||
expandedPanelsInDomOrder.find((panel) => panel.id === preferredPanelId) ||
|
||||
focusedExpandedPanel;
|
||||
// Match the left lane: allocation order follows the latest explicit
|
||||
// disclosure, not DOM order. A focused panel is the fallback owner so
|
||||
// temporary presentation collapse never strands keyboard focus.
|
||||
const expandedPanels = preferredExpandedPanel
|
||||
? [
|
||||
preferredExpandedPanel,
|
||||
...expandedPanelsInDomOrder.filter(
|
||||
(panel) => panel !== preferredExpandedPanel,
|
||||
),
|
||||
]
|
||||
: expandedPanelsInDomOrder;
|
||||
const expandedAvailableHeight = Math.max(
|
||||
0,
|
||||
safeBottom -
|
||||
@@ -253,6 +285,28 @@ export function layoutRightPanelRail({
|
||||
naturalHeights: expandedPanels.map((panel) => naturalHeights.get(panel)),
|
||||
availableHeight: expandedAvailableHeight,
|
||||
});
|
||||
// Other HUD variants restore automatic collapse at the start of each pass.
|
||||
// Do not immediately collapse those panels again and schedule a loop.
|
||||
const autoCollapseIndices =
|
||||
hud.visible && hud.variant === 'tactical'
|
||||
? panelStackAutoCollapseIndices({
|
||||
naturalHeights: expandedPanels.map((panel) =>
|
||||
naturalHeights.get(panel),
|
||||
),
|
||||
allocatedHeights: expandedHeights,
|
||||
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
|
||||
})
|
||||
: [];
|
||||
if (!isCollapseRetry && autoCollapseIndices.length) {
|
||||
for (const index of autoCollapseIndices) {
|
||||
const panel = expandedPanels[index];
|
||||
panel.classList.add('collapsed', 'layout-auto-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
pendingCollapseRetries.add(stack);
|
||||
onRetry();
|
||||
return;
|
||||
}
|
||||
// Write-if-changed. This pass runs on the 500 ms stats cadence. Rewriting
|
||||
// an unchanged allocation wakes the world-overlay occluder observer and
|
||||
// defeats parked-idle render savings. Measurement leaves these values intact.
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
/**
|
||||
* Vertical tab strip for the right context rail: one icon tab per rail
|
||||
* panel, the open panel beside the strip, at most one panel open. The panels'
|
||||
* `collapsed` classes remain the only state; the strip mirrors them and asks
|
||||
* the owner (setPanelCollapsed) for every change, so persistence, share links
|
||||
* and the rail layout pass are untouched. Phones hide the strip and keep their
|
||||
* sheet shell (mobileShell.js).
|
||||
*
|
||||
* The open tab and its panel are drawn as one surface by a join element the
|
||||
* strip shapes from both (railJoinPath); styles/right-rail-tabs.css has the
|
||||
* layers.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Marks the rail while its strip is mounted; styles/right-rail-tabs.css keys
|
||||
* the tab geometry on it, so a rail without a strip keeps its plain layout.
|
||||
*/
|
||||
const RAIL_TABS_ATTRIBUTE = 'data-rail-tabs';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/**
|
||||
* Each rail panel's tab glyph (Material Symbols). index.html subsets the icon
|
||||
* font, so its `icon_names` must carry every one.
|
||||
*/
|
||||
export const RAIL_TAB_ICONS = Object.freeze({
|
||||
'pp-toggles': 'tune',
|
||||
'cctv-panel': 'videocam',
|
||||
'street-level-panel': 'streetview',
|
||||
'global-context-panel': 'travel_explore',
|
||||
'weather-panel': 'cloud',
|
||||
'recent-imagery-panel': 'satellite_alt',
|
||||
});
|
||||
|
||||
/**
|
||||
* The tab keyboard navigation moves to in a vertical tablist: Down and Up
|
||||
* wrap, Home and End jump. Any other key keeps the current index.
|
||||
* @param {string} key KeyboardEvent.key.
|
||||
* @param {number} index Index of the focused tab.
|
||||
* @param {number} count Visible tab count.
|
||||
* @returns {number} Target index, or -1 when there is no tab.
|
||||
*/
|
||||
export function nextRailTabIndex(key, index, count) {
|
||||
if (!(count > 0)) return -1;
|
||||
const current = Math.max(0, Math.min(count - 1, index));
|
||||
switch (key) {
|
||||
case 'ArrowDown':
|
||||
return (current + 1) % count;
|
||||
case 'ArrowUp':
|
||||
return (current - 1 + count) % count;
|
||||
case 'Home':
|
||||
return 0;
|
||||
case 'End':
|
||||
return count - 1;
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG path of the panel with the open tab attached to its right edge, traced
|
||||
* clockwise from the top-left corner, in rail pixels.
|
||||
*
|
||||
* Round: the panel's corners and the tab's outer corners are convex arcs and
|
||||
* the two inner corners where the tab's top and bottom edges meet the panel's
|
||||
* right edge are concave fillets. A tab level with the panel's top shares its
|
||||
* top edge, so that corner and the upper fillet are dropped. Chamfer (Cyber):
|
||||
* the frame's 10px chamfers and 6px calibration bay (cyber.css,
|
||||
* --cyber-panel-frame) and a square tab. `inset` moves the whole outline
|
||||
* inward; 0.5 centres a one-pixel stroke just inside the edge.
|
||||
*
|
||||
* @param {object} g
|
||||
* @param {{x0: number, y0: number, x1: number, y1: number}} g.panel
|
||||
* @param {{top: number, bottom: number, right: number}} g.tab The tab's
|
||||
* extent; its left edge is the panel's right edge.
|
||||
* @param {number} [g.corner] Panel corner radius, or the chamfer size.
|
||||
* @param {number} [g.tabRadius] The tab's outer corner radius.
|
||||
* @param {number} [g.fillet] Concave fillet radius at the junction.
|
||||
* @param {'round'|'chamfer'} [g.style]
|
||||
* @param {number} [g.inset]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function railJoinPath({
|
||||
panel,
|
||||
tab,
|
||||
corner = 16,
|
||||
tabRadius = 10,
|
||||
fillet = 8,
|
||||
style = 'round',
|
||||
inset = 0,
|
||||
}) {
|
||||
const n = (value) => Math.round(value * 100) / 100;
|
||||
const P = (x, y) => `${n(x)} ${n(y)}`;
|
||||
const x0 = panel.x0 + inset;
|
||||
const y0 = panel.y0 + inset;
|
||||
const x1 = panel.x1 - inset;
|
||||
const y1 = panel.y1 - inset;
|
||||
const ty0 = tab.top + inset;
|
||||
const ty1 = tab.bottom - inset;
|
||||
const tx1 = tab.right - inset;
|
||||
if (style === 'chamfer') {
|
||||
const c = corner;
|
||||
const cx = (panel.x0 + panel.x1) / 2;
|
||||
// The tab's edges along the panel stay between the chamfers.
|
||||
const tyA = Math.max(ty0, y0 + c);
|
||||
const tyB = Math.max(tyA, Math.min(ty1, y1 - c));
|
||||
const edge = [
|
||||
`L ${P(x1, tyA)}`,
|
||||
`L ${P(tx1, tyA)}`,
|
||||
`L ${P(tx1, tyB)}`,
|
||||
`L ${P(x1, tyB)}`,
|
||||
];
|
||||
return [
|
||||
`M ${P(x0, y0 + c)}`,
|
||||
`L ${P(x0 + c, y0)}`,
|
||||
`L ${P(cx - 25, y0)}`,
|
||||
`L ${P(cx - 18, y0 + 6)}`,
|
||||
`L ${P(cx + 18, y0 + 6)}`,
|
||||
`L ${P(cx + 25, y0)}`,
|
||||
`L ${P(x1 - c, y0)}`,
|
||||
`L ${P(x1, y0 + c)}`,
|
||||
...edge,
|
||||
`L ${P(x1, y1 - c)}`,
|
||||
`L ${P(x1 - c, y1)}`,
|
||||
`L ${P(x0 + c, y1)}`,
|
||||
`L ${P(x0, y1 - c)}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
const R = Math.max(0, corner - inset);
|
||||
const r = Math.max(0, tabRadius - inset);
|
||||
const f = fillet + inset;
|
||||
const d = [];
|
||||
let at = null;
|
||||
const same = (x, y) => at && at[0] === n(x) && at[1] === n(y);
|
||||
const move = (x, y) => {
|
||||
d.push(`M ${P(x, y)}`);
|
||||
at = [n(x), n(y)];
|
||||
};
|
||||
const line = (x, y) => {
|
||||
if (same(x, y)) return;
|
||||
d.push(`L ${P(x, y)}`);
|
||||
at = [n(x), n(y)];
|
||||
};
|
||||
// sweep 1 turns clockwise (convex), 0 counter-clockwise (concave); a zero
|
||||
// radius is a plain corner.
|
||||
const arc = (radius, sweep, x, y) => {
|
||||
if (!(radius > 0)) return line(x, y);
|
||||
d.push(`A ${n(radius)} ${n(radius)} 0 0 ${sweep} ${P(x, y)}`);
|
||||
at = [n(x), n(y)];
|
||||
};
|
||||
// The run of panel edge above or below the tab may be shorter than a fillet
|
||||
// plus a corner: shrink them to what fits, down to a straight edge shared
|
||||
// with the panel's top or bottom.
|
||||
const fit = (room) => {
|
||||
const fillet = Math.min(f, Math.max(0, room - R));
|
||||
return { fillet, corner: Math.min(R, Math.max(0, room - fillet)) };
|
||||
};
|
||||
move(x0 + R, y0);
|
||||
const first = tab.top - panel.y0 < 0.5;
|
||||
const last = panel.y1 - tab.bottom < 0.5;
|
||||
const above = fit(ty0 - y0);
|
||||
const below = fit(y1 - ty1);
|
||||
if (first) {
|
||||
line(tx1 - r, y0);
|
||||
arc(r, 1, tx1, y0 + r);
|
||||
} else {
|
||||
line(x1 - above.corner, y0);
|
||||
arc(above.corner, 1, x1, y0 + above.corner);
|
||||
line(x1, ty0 - above.fillet);
|
||||
arc(above.fillet, 0, x1 + above.fillet, ty0);
|
||||
line(tx1 - r, ty0);
|
||||
arc(r, 1, tx1, ty0 + r);
|
||||
}
|
||||
line(tx1, ty1 - r);
|
||||
arc(r, 1, tx1 - r, ty1);
|
||||
if (!last) {
|
||||
line(x1 + below.fillet, ty1);
|
||||
arc(below.fillet, 0, x1, ty1 + below.fillet);
|
||||
line(x1, y1 - below.corner);
|
||||
arc(below.corner, 1, x1 - below.corner, y1);
|
||||
}
|
||||
line(x0 + R, y1);
|
||||
arc(R, 1, x0, y1 - R);
|
||||
line(x0, y0 + R);
|
||||
arc(R, 1, x0 + R, y0);
|
||||
d.push('Z');
|
||||
return d.join(' ');
|
||||
}
|
||||
|
||||
/** The tab's name: the panel's own header title, already translated. */
|
||||
function panelTitle(panel) {
|
||||
return (
|
||||
panel
|
||||
.querySelector?.('.panel-title, .pp-header-label')
|
||||
?.textContent?.trim() || panel.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the strip inside `rail` and keep it in step with the rail's panels.
|
||||
* @param {object} input
|
||||
* @param {HTMLElement} input.rail The right context rail.
|
||||
* @param {(panelId: string, collapsed: boolean, options: object) => void} input.setPanelCollapsed
|
||||
* Owner callback; the strip never touches panel classes itself.
|
||||
* @param {Document} [input.documentRef]
|
||||
* @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }}
|
||||
*/
|
||||
export function createRightRailTabs({
|
||||
rail,
|
||||
setPanelCollapsed,
|
||||
documentRef = rail.ownerDocument,
|
||||
}) {
|
||||
const column = documentRef.createElement('div');
|
||||
column.id = 'right-rail-column';
|
||||
const strip = documentRef.createElement('div');
|
||||
strip.id = 'right-rail-tabs';
|
||||
strip.setAttribute('role', 'tablist');
|
||||
strip.setAttribute('aria-orientation', 'vertical');
|
||||
strip.setAttribute('aria-label', t('Panels'));
|
||||
column.append(strip);
|
||||
rail.setAttribute(RAIL_TABS_ATTRIBUTE, 'vertical');
|
||||
|
||||
// The join: shadow under, glass, outline over; all shaped by one path.
|
||||
const svg = (tag) => documentRef.createElementNS(SVG_NS, tag);
|
||||
const join = documentRef.createElement('div');
|
||||
join.className = 'right-rail-join';
|
||||
join.setAttribute('aria-hidden', 'true');
|
||||
join.hidden = true;
|
||||
const shadow = svg('svg');
|
||||
shadow.setAttribute('class', 'right-rail-join-shadow');
|
||||
const shadowPath = svg('path');
|
||||
shadow.append(shadowPath);
|
||||
const glass = documentRef.createElement('div');
|
||||
glass.setAttribute('class', 'right-rail-join-glass');
|
||||
const outline = svg('svg');
|
||||
outline.setAttribute('class', 'right-rail-join-outline');
|
||||
const outlinePath = svg('path');
|
||||
outline.append(outlinePath);
|
||||
join.append(shadow, glass, outline);
|
||||
|
||||
/** @type {Map<string, {tab: HTMLElement, icon: HTMLElement}>} */
|
||||
const entries = new Map();
|
||||
const observed = new WeakSet();
|
||||
let destroyed = false;
|
||||
|
||||
const entryFor = (panel) => {
|
||||
let entry = entries.get(panel.id);
|
||||
if (entry) return entry;
|
||||
const tab = documentRef.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'right-rail-tab';
|
||||
tab.dataset.railTab = panel.id;
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.setAttribute('aria-controls', panel.id);
|
||||
const icon = documentRef.createElement('span');
|
||||
icon.className = 'right-rail-tab-icon material-symbols-outlined';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = RAIL_TAB_ICONS[panel.id] || 'more_horiz';
|
||||
tab.append(icon);
|
||||
entry = { tab, icon };
|
||||
entries.set(panel.id, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const Observer =
|
||||
documentRef.defaultView?.MutationObserver ?? globalThis.MutationObserver;
|
||||
const observer =
|
||||
typeof Observer === 'function' ? new Observer(() => sync()) : null;
|
||||
// Panel heights follow their content and allocation: re-measure the join
|
||||
// when the strip or a panel changes size.
|
||||
const Resize = documentRef.defaultView?.ResizeObserver;
|
||||
const resizer =
|
||||
typeof Resize === 'function' ? new Resize(() => sync()) : null;
|
||||
resizer?.observe(strip);
|
||||
const observe = (panel) => {
|
||||
if (observed.has(panel)) return;
|
||||
observed.add(panel);
|
||||
resizer?.observe(panel);
|
||||
observer?.observe(panel, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'hidden'],
|
||||
});
|
||||
};
|
||||
|
||||
/** A join token from the stylesheet (--right-rail-*), in pixels. */
|
||||
const token = (computed, name, fallback) => {
|
||||
const value = parseFloat(computed?.getPropertyValue?.(name));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Publish the join: the open tab's bottom on the rail (--rail-tab-bottom,
|
||||
* rail pixels; the stylesheet sets the panel's minimum height from it) and
|
||||
* the joined silhouette on the join's layers. The rail can be taller than
|
||||
* its panel in the focus layout, so the join is sized from the panel,
|
||||
* extended down to cover the tab if that is shorter, but never past the
|
||||
* rail's lane (--right-stack-max-height).
|
||||
*/
|
||||
function placeJoin(tab, panel) {
|
||||
if (!tab) {
|
||||
rail.style.removeProperty('--rail-tab-bottom');
|
||||
join.hidden = true;
|
||||
return;
|
||||
}
|
||||
const top = tab.offsetTop;
|
||||
rail.style.setProperty('--rail-tab-bottom', `${top + tab.offsetHeight}px`);
|
||||
const computed = documentRef.defaultView?.getComputedStyle?.(rail);
|
||||
const corner = token(computed, '--right-rail-corner', 16);
|
||||
const tabRadius = token(computed, '--right-rail-tab-radius', 10);
|
||||
const fillet = token(computed, '--right-rail-fillet', 8);
|
||||
const lane = token(computed, '--right-stack-max-height', Infinity);
|
||||
const chamfer =
|
||||
computed?.getPropertyValue?.('--right-rail-join-style').trim() ===
|
||||
'chamfer';
|
||||
// Tab offsets are relative to the strip, the strip's to the column, the
|
||||
// column's to the rail.
|
||||
const stripTop = (column.offsetTop || 0) + (strip.offsetTop || 0);
|
||||
const stripLeft = (column.offsetLeft || 0) + (strip.offsetLeft || 0);
|
||||
const tabTop = stripTop + top;
|
||||
const tabBottom = tabTop + tab.offsetHeight;
|
||||
const geometry = {
|
||||
panel: {
|
||||
x0: panel.offsetLeft,
|
||||
y0: panel.offsetTop,
|
||||
x1: panel.offsetLeft + panel.offsetWidth,
|
||||
y1: Math.max(
|
||||
panel.offsetTop + panel.offsetHeight,
|
||||
Math.min(lane, tabBottom + fillet + 4 + corner),
|
||||
),
|
||||
},
|
||||
tab: {
|
||||
top: tabTop,
|
||||
bottom: tabBottom,
|
||||
right: stripLeft + tab.offsetLeft + tab.offsetWidth,
|
||||
},
|
||||
corner,
|
||||
tabRadius,
|
||||
fillet,
|
||||
style: chamfer ? 'chamfer' : 'round',
|
||||
};
|
||||
const shape = railJoinPath(geometry);
|
||||
const width = rail.clientWidth;
|
||||
const height = geometry.panel.y1;
|
||||
join.style.height = `${height}px`;
|
||||
glass.style.clipPath = `path('${shape}')`;
|
||||
shadowPath.setAttribute('d', shape);
|
||||
// Everything but the silhouette: the shadow it casts stays, the fill goes.
|
||||
shadow.style.clipPath = `path(evenodd, 'M -80 -80 H ${width + 80} V ${height + 80} H -80 Z ${shape}')`;
|
||||
outlinePath.setAttribute('d', railJoinPath({ ...geometry, inset: 0.5 }));
|
||||
join.hidden = false;
|
||||
}
|
||||
|
||||
/** Reconcile tabs with the rail: order, visibility, selection, names. */
|
||||
function sync() {
|
||||
if (destroyed) return;
|
||||
// The column precedes its panels in the tab order, so Tab from the open
|
||||
// tab reaches the open panel. The rail owner moves panels in at start-up;
|
||||
// keep the column first afterwards too.
|
||||
if (rail.firstElementChild !== column) rail.prepend(column);
|
||||
if (join.parentElement !== rail) rail.append(join);
|
||||
// Hidden panels too: Weather and Recent Imagery arrive hidden and their
|
||||
// owners reveal them by attribute, which must create their tabs.
|
||||
for (const panel of rail.children)
|
||||
if (panel.matches?.('[data-panel-id]')) observe(panel);
|
||||
const panels = [...rail.children].filter(
|
||||
(panel) => panel.matches?.('[data-panel-id]') && !panel.hidden,
|
||||
);
|
||||
const focusOwner =
|
||||
panels.find((panel) => !panel.classList.contains('collapsed')) ||
|
||||
panels[0];
|
||||
// Move a tab only when it is out of place: reinserting the focused tab
|
||||
// would drop keyboard focus on every class change.
|
||||
let cursor = strip.firstElementChild;
|
||||
let open = null;
|
||||
for (const panel of panels) {
|
||||
const { tab } = entryFor(panel);
|
||||
if (tab !== cursor) strip.insertBefore(tab, cursor);
|
||||
else cursor = cursor.nextElementSibling;
|
||||
const collapsed = panel.classList.contains('collapsed');
|
||||
if (!collapsed && !open) open = { tab, panel };
|
||||
const name = panelTitle(panel);
|
||||
tab.setAttribute('aria-label', name);
|
||||
tab.setAttribute('aria-selected', String(!collapsed));
|
||||
tab.tabIndex = panel === focusOwner ? 0 : -1;
|
||||
tab.title = t(collapsed ? msg('Expand {name}') : msg('Collapse {name}'), {
|
||||
name,
|
||||
});
|
||||
}
|
||||
const live = new Set(panels.map((panel) => panel.id));
|
||||
for (const [id, entry] of entries) {
|
||||
if (live.has(id)) continue;
|
||||
entry.tab.remove();
|
||||
entries.delete(id);
|
||||
}
|
||||
placeJoin(open?.tab, open?.panel);
|
||||
}
|
||||
|
||||
const onClick = (event) => {
|
||||
const tab = event.target?.closest?.('[data-rail-tab]');
|
||||
if (!tab) return;
|
||||
const panel = documentRef.getElementById(tab.dataset.railTab);
|
||||
if (!panel) return;
|
||||
setPanelCollapsed(panel.id, !panel.classList.contains('collapsed'), {
|
||||
explicit: true,
|
||||
});
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
const current = event.target?.closest?.('[data-rail-tab]');
|
||||
if (!current) return;
|
||||
const visible = [...strip.children].filter((tab) => !tab.hidden);
|
||||
const index = visible.indexOf(current);
|
||||
const next = nextRailTabIndex(event.key, index, visible.length);
|
||||
if (next < 0 || next === index) return;
|
||||
event.preventDefault();
|
||||
visible[next].focus();
|
||||
};
|
||||
strip.addEventListener('click', onClick);
|
||||
strip.addEventListener('keydown', onKeydown);
|
||||
observer?.observe(rail, { childList: true });
|
||||
rail.prepend(column);
|
||||
rail.append(join);
|
||||
sync();
|
||||
|
||||
return {
|
||||
element: strip,
|
||||
sync,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
observer?.disconnect();
|
||||
resizer?.disconnect();
|
||||
strip.removeEventListener('click', onClick);
|
||||
strip.removeEventListener('keydown', onKeydown);
|
||||
column.remove();
|
||||
join.remove();
|
||||
rail.removeAttribute(RAIL_TABS_ATTRIBUTE);
|
||||
placeJoin(null, null);
|
||||
entries.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { fire, makeDocument } from '../testSupport/fakeDom.mjs';
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
RAIL_TAB_ICONS,
|
||||
createRightRailTabs,
|
||||
nextRailTabIndex,
|
||||
railJoinPath,
|
||||
} from './rightRailTabs.js';
|
||||
|
||||
function panel(
|
||||
doc,
|
||||
id,
|
||||
{ title, titleClass = 'panel-title', collapsed = true, hidden = false },
|
||||
) {
|
||||
const node = doc.createElement('div');
|
||||
node.id = id;
|
||||
node.dataset.panelId = id;
|
||||
node.hidden = hidden;
|
||||
if (collapsed) node.classList.add('collapsed');
|
||||
const header = doc.createElement('div');
|
||||
header.className = 'panel-header';
|
||||
const label = doc.createElement('span');
|
||||
label.className = titleClass;
|
||||
label.textContent = title;
|
||||
header.append(label);
|
||||
node.append(header);
|
||||
return node;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const doc = makeDocument();
|
||||
const rail = doc.createElement('aside');
|
||||
rail.id = 'right-context-rail';
|
||||
const display = panel(doc, 'pp-toggles', {
|
||||
title: 'DISPLAY',
|
||||
titleClass: 'pp-header-label',
|
||||
});
|
||||
const cctv = panel(doc, 'cctv-panel', { title: 'CCTV' });
|
||||
const weather = panel(doc, 'weather-panel', {
|
||||
title: 'WEATHER',
|
||||
collapsed: false,
|
||||
hidden: true,
|
||||
});
|
||||
const context = panel(doc, 'global-context-panel', { title: 'CONTEXT' });
|
||||
rail.append(display, cctv, weather, context);
|
||||
doc.body.append(rail);
|
||||
const calls = [];
|
||||
const tabs = createRightRailTabs({
|
||||
rail,
|
||||
documentRef: doc,
|
||||
setPanelCollapsed: (...args) => calls.push(args),
|
||||
});
|
||||
const strip = tabs.element;
|
||||
const column = doc.getElementById('right-rail-column');
|
||||
const join = rail.querySelector('.right-rail-join');
|
||||
const tabFor = (id) =>
|
||||
strip.children.find((tab) => tab.dataset.railTab === id);
|
||||
const iconOf = (tab) =>
|
||||
tab.children.find((child) =>
|
||||
child.classList.contains('right-rail-tab-icon'),
|
||||
);
|
||||
return {
|
||||
doc,
|
||||
rail,
|
||||
display,
|
||||
cctv,
|
||||
weather,
|
||||
context,
|
||||
tabs,
|
||||
strip,
|
||||
column,
|
||||
join,
|
||||
calls,
|
||||
tabFor,
|
||||
iconOf,
|
||||
};
|
||||
}
|
||||
|
||||
test('builds one icon tab per visible rail panel, in rail order, named from the panel header', () => {
|
||||
const f = fixture();
|
||||
assert.equal(
|
||||
f.rail.children[0],
|
||||
f.column,
|
||||
'the column precedes its panels in the tab order',
|
||||
);
|
||||
assert.deepEqual(f.column.children, [f.strip]);
|
||||
assert.equal(f.strip.getAttribute('role'), 'tablist');
|
||||
assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical');
|
||||
assert.equal(f.rail.getAttribute('data-rail-tabs'), 'vertical');
|
||||
assert.deepEqual(
|
||||
f.strip.children.map((tab) => [
|
||||
tab.dataset.railTab,
|
||||
f.iconOf(tab).textContent,
|
||||
tab.getAttribute('aria-label'),
|
||||
tab.getAttribute('aria-controls'),
|
||||
]),
|
||||
[
|
||||
['pp-toggles', 'tune', 'DISPLAY', 'pp-toggles'],
|
||||
['cctv-panel', 'videocam', 'CCTV', 'cctv-panel'],
|
||||
[
|
||||
'global-context-panel',
|
||||
'travel_explore',
|
||||
'CONTEXT',
|
||||
'global-context-panel',
|
||||
],
|
||||
],
|
||||
'a hidden panel (Weather off) has no tab',
|
||||
);
|
||||
for (const tab of f.strip.children) {
|
||||
assert.equal(tab.getAttribute('role'), 'tab');
|
||||
assert.equal(tab.type, 'button');
|
||||
assert.equal(f.iconOf(tab).getAttribute('aria-hidden'), 'true');
|
||||
}
|
||||
});
|
||||
|
||||
test('the icon font subset carries every rail tab glyph', () => {
|
||||
const html = readFileSync(
|
||||
new URL('../../index.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const subset = /icon_names=([a-z0-9_,]+)/.exec(html)[1].split(',');
|
||||
for (const glyph of Object.values(RAIL_TAB_ICONS))
|
||||
assert.ok(subset.includes(glyph), glyph);
|
||||
});
|
||||
|
||||
test('mirrors collapsed state into aria-selected, a roving tabindex and titles', () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(
|
||||
f.strip.children.map((tab) => tab.getAttribute('aria-selected')),
|
||||
['false', 'false', 'false'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.strip.children.map((tab) => tab.tabIndex),
|
||||
[0, -1, -1],
|
||||
'nothing open: the first tab is reachable',
|
||||
);
|
||||
assert.equal(f.tabFor('cctv-panel').title, 'Expand CCTV');
|
||||
f.cctv.classList.remove('collapsed');
|
||||
f.tabs.sync();
|
||||
assert.deepEqual(
|
||||
f.strip.children.map((tab) => tab.getAttribute('aria-selected')),
|
||||
['false', 'true', 'false'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.strip.children.map((tab) => tab.tabIndex),
|
||||
[-1, 0, -1],
|
||||
'the open panel owns the tab stop',
|
||||
);
|
||||
assert.equal(f.tabFor('cctv-panel').title, 'Collapse CCTV');
|
||||
});
|
||||
|
||||
test('a click asks the owner for an explicit toggle and never touches the panel itself', () => {
|
||||
const f = fixture();
|
||||
fire(f.tabFor('cctv-panel'), 'click');
|
||||
assert.deepEqual(f.calls, [['cctv-panel', false, { explicit: true }]]);
|
||||
assert.equal(
|
||||
f.cctv.classList.contains('collapsed'),
|
||||
true,
|
||||
'the strip leaves the class to the owner',
|
||||
);
|
||||
f.cctv.classList.remove('collapsed');
|
||||
f.tabs.sync();
|
||||
fire(f.tabFor('cctv-panel'), 'click');
|
||||
assert.deepEqual(f.calls.at(-1), ['cctv-panel', true, { explicit: true }]);
|
||||
fire(f.strip, 'click');
|
||||
assert.equal(f.calls.length, 2, 'a click beside the tabs does nothing');
|
||||
});
|
||||
|
||||
test('arrow keys move focus through the visible tabs and wrap; Home and End jump', () => {
|
||||
assert.equal(nextRailTabIndex('ArrowDown', 2, 3), 0);
|
||||
assert.equal(nextRailTabIndex('ArrowUp', 0, 3), 2);
|
||||
assert.equal(nextRailTabIndex('Home', 2, 3), 0);
|
||||
assert.equal(nextRailTabIndex('End', 0, 3), 2);
|
||||
assert.equal(nextRailTabIndex('Enter', 1, 3), 1);
|
||||
assert.equal(nextRailTabIndex('ArrowDown', 0, 0), -1);
|
||||
const f = fixture();
|
||||
const first = f.tabFor('pp-toggles');
|
||||
first.focus();
|
||||
const down = fire(first, 'keydown', { key: 'ArrowDown' });
|
||||
assert.equal(f.doc.activeElement, f.tabFor('cctv-panel'));
|
||||
assert.equal(down.defaultPrevented, true);
|
||||
fire(f.doc.activeElement, 'keydown', { key: 'ArrowUp' });
|
||||
fire(f.doc.activeElement, 'keydown', { key: 'ArrowUp' });
|
||||
assert.equal(
|
||||
f.doc.activeElement,
|
||||
f.tabFor('global-context-panel'),
|
||||
'Up from the first tab wraps to the last',
|
||||
);
|
||||
const enter = fire(f.doc.activeElement, 'keydown', { key: 'Enter' });
|
||||
assert.equal(
|
||||
enter.defaultPrevented,
|
||||
false,
|
||||
'activation keys stay with the button',
|
||||
);
|
||||
assert.deepEqual(f.calls, [], 'moving focus opens nothing');
|
||||
});
|
||||
|
||||
test('a panel that appears gets its tab in rail order; one that leaves loses it', () => {
|
||||
const f = fixture();
|
||||
f.weather.hidden = false;
|
||||
f.tabs.sync();
|
||||
assert.deepEqual(
|
||||
f.strip.children.map((tab) => tab.dataset.railTab),
|
||||
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
|
||||
);
|
||||
assert.equal(f.tabFor('weather-panel').getAttribute('aria-selected'), 'true');
|
||||
const cctvTab = f.tabFor('cctv-panel');
|
||||
f.cctv.remove();
|
||||
f.tabs.sync();
|
||||
assert.deepEqual(
|
||||
f.strip.children.map((tab) => tab.dataset.railTab),
|
||||
['pp-toggles', 'weather-panel', 'global-context-panel'],
|
||||
);
|
||||
assert.equal(cctvTab.parentElement, null);
|
||||
assert.equal(f.tabFor('pp-toggles').tabIndex, -1);
|
||||
assert.equal(f.tabFor('weather-panel').tabIndex, 0);
|
||||
});
|
||||
|
||||
test('a sync that changes nothing keeps the same tab elements in place', () => {
|
||||
const f = fixture();
|
||||
const before = [...f.strip.children];
|
||||
f.tabFor('cctv-panel').focus();
|
||||
f.tabs.sync();
|
||||
assert.deepEqual([...f.strip.children], before);
|
||||
assert.equal(f.doc.activeElement, f.tabFor('cctv-panel'));
|
||||
});
|
||||
|
||||
test('publishes the open tab extent on the rail and keeps a join element under the panels', () => {
|
||||
const f = fixture();
|
||||
// The strip's offsets are relative to its column, the column's to the rail.
|
||||
Object.assign(f.column, { offsetTop: 0, offsetLeft: 320 });
|
||||
Object.assign(f.strip, { offsetLeft: 10 });
|
||||
const { props } = f.rail.style;
|
||||
assert.equal(f.join.parentElement, f.rail);
|
||||
assert.equal(f.join.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(f.join.hidden, true, 'nothing open, nothing to join');
|
||||
assert.deepEqual(
|
||||
f.join.children.map((layer) => layer.getAttribute('class')),
|
||||
[
|
||||
'right-rail-join-shadow',
|
||||
'right-rail-join-glass',
|
||||
'right-rail-join-outline',
|
||||
],
|
||||
);
|
||||
const tab = f.tabFor('cctv-panel');
|
||||
Object.assign(tab, {
|
||||
offsetTop: 86,
|
||||
offsetLeft: 0,
|
||||
offsetHeight: 56,
|
||||
offsetWidth: 42,
|
||||
});
|
||||
Object.assign(f.cctv, {
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
offsetHeight: 300,
|
||||
offsetWidth: 330,
|
||||
});
|
||||
f.cctv.classList.remove('collapsed');
|
||||
f.tabs.sync();
|
||||
assert.deepEqual([...props], [['--rail-tab-bottom', '142px']]);
|
||||
assert.equal(f.join.hidden, false);
|
||||
const [, glass] = f.join.children;
|
||||
assert.match(
|
||||
glass.style.clipPath,
|
||||
/^path\('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 78 /,
|
||||
);
|
||||
f.cctv.classList.add('collapsed');
|
||||
f.tabs.sync();
|
||||
assert.deepEqual([...props], [], 'no open panel, no join');
|
||||
assert.equal(f.join.hidden, true);
|
||||
f.tabs.destroy();
|
||||
assert.equal(f.join.parentElement, null);
|
||||
});
|
||||
|
||||
test('railJoinPath traces the panel with the tab attached: corners, fillets, tab corners', () => {
|
||||
const geometry = {
|
||||
panel: { x0: 0, y0: 0, x1: 330, y1: 378 },
|
||||
tab: { top: 85, bottom: 141, right: 372 },
|
||||
corner: 16,
|
||||
tabRadius: 10,
|
||||
fillet: 8,
|
||||
};
|
||||
assert.equal(
|
||||
railJoinPath(geometry),
|
||||
'M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 77 A 8 8 0 0 0 338 85 ' +
|
||||
'L 362 85 A 10 10 0 0 1 372 95 L 372 131 A 10 10 0 0 1 362 141 ' +
|
||||
'L 338 141 A 8 8 0 0 0 330 149 L 330 362 A 16 16 0 0 1 314 378 ' +
|
||||
'L 16 378 A 16 16 0 0 1 0 362 L 0 16 A 16 16 0 0 1 16 0 Z',
|
||||
);
|
||||
assert.equal(
|
||||
railJoinPath({ ...geometry, tab: { top: 0, bottom: 79, right: 372 } }),
|
||||
'M 16 0 L 362 0 A 10 10 0 0 1 372 10 L 372 69 A 10 10 0 0 1 362 79 ' +
|
||||
'L 338 79 A 8 8 0 0 0 330 87 L 330 362 A 16 16 0 0 1 314 378 ' +
|
||||
'L 16 378 A 16 16 0 0 1 0 362 L 0 16 A 16 16 0 0 1 16 0 Z',
|
||||
'a tab level with the top shares the top edge: no corner, no upper fillet',
|
||||
);
|
||||
const stroke = railJoinPath({ ...geometry, inset: 0.5 });
|
||||
assert.match(stroke, /^M 16 0\.5 L 314 0\.5 A 15\.5 15\.5 0 0 1 329\.5 16 /);
|
||||
assert.match(
|
||||
stroke,
|
||||
/ L 329\.5 77 A 8\.5 8\.5 0 0 0 338 85\.5 /,
|
||||
'an inset grows the concave fillets and shrinks the convex corners',
|
||||
);
|
||||
const chamfer = railJoinPath({
|
||||
...geometry,
|
||||
panel: { x0: 0, y0: 0, x1: 312, y1: 374 },
|
||||
corner: 10,
|
||||
style: 'chamfer',
|
||||
});
|
||||
assert.equal(
|
||||
chamfer,
|
||||
'M 0 10 L 10 0 L 131 0 L 138 6 L 174 6 L 181 0 L 302 0 L 312 10 ' +
|
||||
'L 312 85 L 372 85 L 372 141 L 312 141 L 312 364 L 302 374 ' +
|
||||
'L 10 374 L 0 364 Z',
|
||||
'Cyber: chamfers, the calibration bay and a square tab',
|
||||
);
|
||||
});
|
||||
|
||||
/** A browser-like MutationObserver over the fake tree. */
|
||||
function installObservers(doc) {
|
||||
const live = [];
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.targets = [];
|
||||
live.push(this);
|
||||
}
|
||||
observe(target, options) {
|
||||
this.targets.push({ target, options });
|
||||
}
|
||||
disconnect() {
|
||||
this.targets = [];
|
||||
}
|
||||
}
|
||||
doc.defaultView = { MutationObserver: FakeMutationObserver };
|
||||
return {
|
||||
attributeChanged(target, name) {
|
||||
for (const observer of live) {
|
||||
const hit = observer.targets.some(
|
||||
({ target: observed, options }) =>
|
||||
observed === target &&
|
||||
options.attributes &&
|
||||
(!options.attributeFilter ||
|
||||
options.attributeFilter.includes(name)),
|
||||
);
|
||||
if (hit)
|
||||
observer.callback([
|
||||
{ type: 'attributes', target, attributeName: name },
|
||||
]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a rail panel hidden at mount gets its tab when its owner unhides it, from the observers alone', () => {
|
||||
const doc = makeDocument();
|
||||
const { attributeChanged } = installObservers(doc);
|
||||
const rail = doc.createElement('aside');
|
||||
rail.id = 'right-context-rail';
|
||||
const display = panel(doc, 'pp-toggles', {
|
||||
title: 'DISPLAY',
|
||||
titleClass: 'pp-header-label',
|
||||
collapsed: false,
|
||||
});
|
||||
const cctv = panel(doc, 'cctv-panel', { title: 'CCTV' });
|
||||
const recent = panel(doc, 'recent-imagery-panel', {
|
||||
title: 'RECENT IMAGERY',
|
||||
hidden: true,
|
||||
});
|
||||
const context = panel(doc, 'global-context-panel', { title: 'CONTEXT' });
|
||||
rail.append(display, cctv, recent, context);
|
||||
doc.body.append(rail);
|
||||
const tabs = createRightRailTabs({
|
||||
rail,
|
||||
documentRef: doc,
|
||||
setPanelCollapsed() {},
|
||||
});
|
||||
const ids = () => tabs.element.children.map((tab) => tab.dataset.railTab);
|
||||
assert.deepEqual(ids(), ['pp-toggles', 'cctv-panel', 'global-context-panel']);
|
||||
// Recent Imagery's layer turns on: its owner removes `hidden`, then the
|
||||
// rail pass marks it collapsed behind the Display owner.
|
||||
recent.hidden = false;
|
||||
attributeChanged(recent, 'hidden');
|
||||
recent.classList.add('rail-owner-collapsed');
|
||||
attributeChanged(recent, 'class');
|
||||
assert.deepEqual(ids(), [
|
||||
'pp-toggles',
|
||||
'cctv-panel',
|
||||
'recent-imagery-panel',
|
||||
'global-context-panel',
|
||||
]);
|
||||
tabs.destroy();
|
||||
});
|
||||
|
||||
test('railJoinPath shrinks the fillet and corner to the room left, down to a shared edge', () => {
|
||||
const base = {
|
||||
panel: { x0: 0, y0: 0, x1: 330, y1: 160 },
|
||||
corner: 16,
|
||||
tabRadius: 10,
|
||||
fillet: 8,
|
||||
};
|
||||
const tight = railJoinPath({
|
||||
...base,
|
||||
tab: { top: 60, bottom: 148, right: 372 },
|
||||
});
|
||||
assert.match(
|
||||
tight,
|
||||
/A 10 10 0 0 1 362 148 L 330 148 A 12 12 0 0 1 318 160 L 16 160 /,
|
||||
'12px below the tab: no fillet, a 12px corner',
|
||||
);
|
||||
const flush = railJoinPath({
|
||||
...base,
|
||||
tab: { top: 60, bottom: 160, right: 372 },
|
||||
});
|
||||
assert.match(
|
||||
flush,
|
||||
/A 10 10 0 0 1 362 160 L 16 160 A 16 16 0 0 1 0 144 /,
|
||||
'a tab level with the panel bottom shares its bottom edge',
|
||||
);
|
||||
const chamfer = railJoinPath({
|
||||
...base,
|
||||
panel: { x0: 0, y0: 0, x1: 312, y1: 160 },
|
||||
tab: { top: 4, bottom: 158, right: 372 },
|
||||
corner: 10,
|
||||
style: 'chamfer',
|
||||
});
|
||||
assert.match(
|
||||
chamfer,
|
||||
/ L 312 10 L 372 10 L 372 150 L 312 150 L 312 150 L 302 160 /,
|
||||
"Cyber keeps the tab's edges between the chamfers",
|
||||
);
|
||||
});
|
||||
|
||||
test('the stylesheet keeps phones on border-box, folds the strip into narrow desktops, keeps icon tabs square and whole without scrolling', () => {
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
assert.match(
|
||||
css,
|
||||
/body\.mobile-shell #right-context-rail \{[^}]*box-sizing: border-box;/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/@media \(min-width: 721px\) and \(max-width: 760px\) \{\s*#right-context-rail\[data-rail-tabs\] \{\s*width: calc\(330px - var\(--right-rail-tabs-inset\)\);/,
|
||||
);
|
||||
// The tabs keep their size: a square icon, never shrunk or rotated.
|
||||
assert.match(
|
||||
css,
|
||||
/\.right-rail-tab \{[^}]*flex: 0 0 auto;[^}]*width: var\(--right-rail-tabs-width\);\s*height: var\(--right-rail-tabs-width\);/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/\.right-rail-tab[^{]*\{[^}]*(writing-mode|mask-image)/,
|
||||
);
|
||||
// The column keeps its natural height: every tab stays whole, nothing
|
||||
// scrolls or clips; only the strip takes the pointer.
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/#right-rail-(column|tabs) \{[^}]*(max-height|overflow|scrollbar-width)/,
|
||||
);
|
||||
assert.match(css, /#right-rail-column \{[^}]*pointer-events: none;/);
|
||||
assert.match(css, /#right-rail-tabs \{[^}]*pointer-events: auto;/);
|
||||
assert.match(css, /--right-rail-panel-cover: min\(/);
|
||||
// The column hides on phones.
|
||||
assert.match(
|
||||
css,
|
||||
/body\.mobile-shell #right-context-rail > #right-rail-column \{\s*display: none;/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/body\.mobile-shell #right-context-rail > #right-rail-tabs/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/body:not\(\.mobile-shell\)\s*#right-context-rail\[data-rail-tabs\]\s*:is\(\s*\[data-collapse-target='pp-toggles'\],\s*\[data-collapse-target='cctv-panel'\],\s*\[data-collapse-target='street-level-panel'\],\s*\[data-collapse-target='global-context-panel'\],\s*\[data-collapse-target='weather-panel'\],\s*\[data-collapse-target='recent-imagery-panel'\]\s*\) \{\s*display: none;/,
|
||||
'the rail panels own collapse buttons are hidden on desktop, the Radio section keeps its own',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy removes the column and stops listening', () => {
|
||||
const f = fixture();
|
||||
const tab = f.tabFor('cctv-panel');
|
||||
f.tabs.destroy();
|
||||
assert.equal(f.column.parentElement, null);
|
||||
assert.equal(f.rail.children.includes(f.column), false);
|
||||
assert.equal(f.rail.getAttribute('data-rail-tabs'), null);
|
||||
fire(tab, 'click');
|
||||
assert.deepEqual(f.calls, []);
|
||||
f.tabs.destroy();
|
||||
});
|
||||
+38
-8
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
/* ── Right rail as vertical tabs ───────────────────────────────────────────
|
||||
A strip of icon tabs (rightRailTabs.js) hugs the rail's right edge and the
|
||||
open panel sits beside it. Panels keep their `collapsed` state; the rail's
|
||||
exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip
|
||||
is the only launcher. Phones keep the sheet shell (mobile-shell.css).
|
||||
|
||||
The open tab and its panel are one surface. The strip builds a join element
|
||||
(.right-rail-join) under the panels: one path traces the panel with the tab
|
||||
attached (rounded corners and concave fillets; Cyber's chamfered frame).
|
||||
The glass layer blurs and tints the backdrop inside the path, one SVG
|
||||
strokes the outline and another casts the shadow, so the junction has no
|
||||
seam and no second blur region. The open panel and the tab draw no surface
|
||||
of their own; the other tabs are plain icons. `data-rail-tabs` arrives on
|
||||
the rail with the strip, so a rail without it keeps its plain geometry; the
|
||||
selectors carry it to outrank the Cyber rail rules that follow this sheet. */
|
||||
|
||||
#right-context-rail[data-rail-tabs] {
|
||||
--right-rail-tabs-width: 34px;
|
||||
/* The bridge the open tab reaches across to its panel. */
|
||||
--right-rail-tabs-gap: 8px;
|
||||
--right-rail-tabs-inset: calc(
|
||||
var(--right-rail-tabs-width) + var(--right-rail-tabs-gap)
|
||||
);
|
||||
/* Join geometry, read by rightRailTabs.js: panel corner, the tab's outer
|
||||
corners, the concave fillets, and round corners or Cyber's chamfers. */
|
||||
--right-rail-corner: var(--panel-radius);
|
||||
--right-rail-tab-radius: 10px;
|
||||
--right-rail-fillet: 8px;
|
||||
--right-rail-join-style: round;
|
||||
--right-rail-join-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
/* Lower fillet, a short straight run, then the panel's corner, within the
|
||||
rail's lane. */
|
||||
--right-rail-panel-cover: min(
|
||||
calc(
|
||||
var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px +
|
||||
var(--right-rail-corner)
|
||||
),
|
||||
var(--right-stack-max-height, 70vh)
|
||||
);
|
||||
/* The theme's rail width stays the panel width; the strip's column is
|
||||
padding beside it. */
|
||||
box-sizing: content-box;
|
||||
padding-right: var(--right-rail-tabs-inset);
|
||||
}
|
||||
|
||||
/* Just above the phone breakpoint the strip's column would push the rail into
|
||||
the left rail: fold it into the panel width instead. */
|
||||
@media (min-width: 721px) and (max-width: 760px) {
|
||||
#right-context-rail[data-rail-tabs] {
|
||||
width: calc(330px - var(--right-rail-tabs-inset));
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] {
|
||||
width: calc(312px - var(--right-rail-tabs-inset));
|
||||
}
|
||||
}
|
||||
|
||||
/* The strip's column keeps its natural height, so every tab stays whole even
|
||||
past a short lane; only the strip takes the pointer. */
|
||||
#right-rail-column {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: var(--right-rail-tabs-inset);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#right-rail-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
/* Room for the open tab's bridge on the left. */
|
||||
padding-left: var(--right-rail-tabs-gap);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The tabs open and close the rail's panels; the headers' own collapse
|
||||
buttons would only duplicate them. Phones keep the sheet shell's buttons. */
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
:is(
|
||||
[data-collapse-target='pp-toggles'],
|
||||
[data-collapse-target='cctv-panel'],
|
||||
[data-collapse-target='street-level-panel'],
|
||||
[data-collapse-target='global-context-panel'],
|
||||
[data-collapse-target='weather-panel'],
|
||||
[data-collapse-target='recent-imagery-panel']
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* A square icon the strip's width; the panel's name is the tab's accessible
|
||||
name and tooltip. */
|
||||
.right-rail-tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: var(--right-rail-tabs-width);
|
||||
height: var(--right-rail-tabs-width);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--right-rail-tab-radius);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.right-rail-tab-icon {
|
||||
color: inherit;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.right-rail-tab:not([aria-selected='true']):hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* The open tab is the icon and hit area over the join's surface; it spans
|
||||
the bridge too, with the icon kept in the strip's column. */
|
||||
.right-rail-tab[aria-selected='true'] {
|
||||
width: calc(var(--right-rail-tabs-width) + var(--right-rail-tabs-gap));
|
||||
margin-left: calc(-1 * var(--right-rail-tabs-gap));
|
||||
padding-left: var(--right-rail-tabs-gap);
|
||||
border-radius: 0 var(--right-rail-tab-radius) var(--right-rail-tab-radius) 0;
|
||||
color: var(--accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* ── The join: surface, outline and shadow of panel plus tab ──────────── */
|
||||
.right-rail-join {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.right-rail-join[hidden],
|
||||
body.mobile-shell #right-context-rail > .right-rail-join {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.right-rail-join > * {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* A black silhouette casts the shadow; its clip keeps only the outside. */
|
||||
.right-rail-join-shadow {
|
||||
filter: drop-shadow(var(--right-rail-join-shadow));
|
||||
}
|
||||
|
||||
.right-rail-join-shadow path {
|
||||
fill: #000;
|
||||
}
|
||||
|
||||
.right-rail-join-glass {
|
||||
background-color: var(--glass-bg);
|
||||
background-image: var(--panel-surface-detail);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
}
|
||||
|
||||
.right-rail-join-outline path {
|
||||
fill: none;
|
||||
stroke: var(--glass-border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* The open panel's card leaves surface, outline and shadow to the join.
|
||||
Cyber's Display and Context frames and the surface contract's card live on
|
||||
a ::before. */
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> #pp-toggles:not(.collapsed),
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> #pp-toggles:not(.collapsed)::before,
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> :is([data-rail-card], [data-panel-surface]),
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> :is(.global-context-panel-inner, [data-panel-surface])::before {
|
||||
border-color: transparent;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* The glow's 0.25 is tuned to sit under the card's surface; without that
|
||||
surface, 0.08 reads the same. */
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .panel-glow {
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
/* The card reaches at least to its tab's lower fillet and corner. In the
|
||||
focus layout the rail's allocation bounds the wrapper; the card's floor is
|
||||
what the measuring pass sees. */
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> :is([data-rail-card], [data-panel-surface]) {
|
||||
min-height: var(--right-rail-panel-cover);
|
||||
}
|
||||
|
||||
/* ── Cyber skin ────────────────────────────────────────────────────────── */
|
||||
/* Cyber panels are chamfered frames (--cyber-panel-frame), not rounded
|
||||
cards: square joins, no fillets, no blur, and the strip starts below the
|
||||
frame's upper chamfer so the first tab meets a straight edge. The frame's
|
||||
red edge bar moves onto the join's glass. */
|
||||
:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] {
|
||||
--right-rail-tab-radius: 0px;
|
||||
--right-rail-fillet: 0px;
|
||||
--right-rail-corner: 10px;
|
||||
--right-rail-join-style: chamfer;
|
||||
--right-rail-join-shadow: 0 10px 28px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] #right-rail-tabs {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Cyber's generic hover/selected button treatment (cyber.css) would paint
|
||||
the tabs; the open tab stays an icon on the shared surface. */
|
||||
:root[data-ui-theme='cyber'] .right-rail-tab:not([aria-selected='true']):hover {
|
||||
background-color: rgba(226, 235, 236, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true'],
|
||||
:root[data-ui-theme='cyber'] .right-rail-tab[aria-selected='true']:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: -3px 0 0 var(--cyber-red) inset;
|
||||
color: var(--cyber-red-bright);
|
||||
}
|
||||
|
||||
/* Cyber gives every .panel-collapsible wrapper a surface with an inset ring
|
||||
(cyber.css); beside the open tab that ring would show as a line. */
|
||||
:root[data-ui-theme='cyber']
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> [data-panel-id]:not(.collapsed) {
|
||||
border-color: transparent;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .right-rail-join-glass {
|
||||
box-shadow: 3px 0 0 rgba(196, 84, 84, 0.2) inset;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* ── Phones keep the sheet shell ───────────────────────────────────────── */
|
||||
/* The content-box rail is a desktop measure; sheets size their box as before. */
|
||||
body.mobile-shell #right-context-rail {
|
||||
box-sizing: border-box;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* The launcher is back in Context's header there (radioPanelPlacement.js). */
|
||||
body.mobile-shell #right-context-rail > #right-rail-column {
|
||||
display: none;
|
||||
}
|
||||
+122
-24
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<aside id="right-context-rail">
|
||||
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
|
||||
<div class="panel-glow"></div>
|
||||
<div class="weather-panel-inner" data-rail-scroller data-rail-card>
|
||||
<div class="weather-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>WEATHER</span>
|
||||
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
|
||||
<div class="panel-glow"></div>
|
||||
<div class="recent-imagery-panel-inner" data-rail-scroller data-rail-card>
|
||||
<div class="recent-imagery-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
|
||||
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="global-context-panel-inner" data-rail-scroller data-rail-card>
|
||||
<div class="global-context-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>CONTEXT</span>
|
||||
<span class="panel-divider"></span>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- CCTV Coverage Panel -->
|
||||
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="cctv-panel-inner" data-rail-scroller data-rail-card>
|
||||
<div class="cctv-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>CCTV</span>
|
||||
<span class="panel-divider"></span>
|
||||
@@ -75,7 +75,7 @@
|
||||
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
|
||||
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="street-level-panel-inner" data-rail-scroller data-rail-card>
|
||||
<div class="street-level-panel-inner" data-rail-scroller>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>STREET LEVEL</span>
|
||||
<span class="panel-divider"></span>
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
Reference in new issue
Block a user