diff --git a/CHANGELOG.md b/CHANGELOG.md index 356289841..134032969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- Each desktop sidebar has an icon rail that lists every panel on that side + and opens one with a click. One panel is open per side in every HUD layout, + so the collapsed panel headers no longer take up the sidebars. Phones keep + the bottom tab bar. The rail is the first view built with Vue and Pinia, + which now ship with the app; see docs/VUE-MIGRATION.md (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfdc06904..3c18bf83d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1545,6 +1545,28 @@ The right rail skips panels hidden by other CSS (computed `display: none`), but keeps the collapsed panels its own exclusive presentation hides, so automatic collapse can still be undone. +## Panel rails + +On desktop each side stack has a floating icon rail (`src/ui/panelNavigation.js`, +rendered by the Vue `PanelRailView` and the kit's `UiRail`) in the screen-edge +gutter beside it. The rail lists every panel the side currently offers, in stack +order: Data Layers and Authoring on the left; Display, CCTV, Weather and Recent +Imagery (while shown), Street Level and Context on the right. Selecting an item +opens its panel through `setPanelCollapsed(id, false, {explicit: true})`; +selecting the open panel's item closes it. The item of the open panel is +pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open. + +One panel is open per desktop side, in every HUD layout. Opening a side panel +by any route (rail, header, keyboard, voice) collapses its peers; restored or +shared state that opens several is normalized by the layout pass, which keeps +the most recently opened panel, then the one holding keyboard focus, then the +first, and marks the others `accordion-collapsed` (presentation only, so share +links still report the saved preference). Collapsed panels are hidden +(`layout-exclusive`) because the rail lists them. The rail lives inside its +stack, so it hides with it in Cockpit (where Authoring's item is also hidden), +recording and clean view. The panel store (`src/ui/stores/panels.js`) mirrors +each side's panels from the DOM; `PanelChrome` remains the disclosure owner. + ## Phone layout At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and @@ -1580,8 +1602,9 @@ 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 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 +side rails neither hide collapsed siblings nor apply the desktop one-panel +accordion at these widths, and the panel rails are hidden, 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. @@ -5622,7 +5645,7 @@ apply to `public/` assets or built-preview serving. - GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state). - 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. +- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. One right-rail panel is open at a time in every HUD layout and collapsed launchers are hidden; the panel rail beside the stack switches between them (see Panel rails). The most recently opened right-rail panel owns the lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. When the open panel exceeds the measured corridor, it scrolls 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 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. @@ -6055,7 +6078,7 @@ After the initial broadside launch profile, the ascent chase camera stays in a r During orbit replay, the camera continues following the selected vehicle but eases its look-at target down toward the vehicle's sub-satellite globe anchor. The range expands when necessary for high-altitude missions, keeping both Earth and the tracked label visible through the full revolution. The active replay clock clamps at the final orbital sample rather than wrapping to ascent progress zero; replay completion therefore leaves the final globe/orbit framing in place and does not return to the launch site. Reconstructed mission orbits use a small downrange launch-to-insertion arc, so their estimated ground track is not artificially drawn directly over the launch pad in top-down views. The ascent remains connected to the ring at its selected insertion point. -Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; DISPLAY may remain open beside CCTV or Context, while CCTV and Context remain mutually exclusive without persisting forced collapses. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely. +Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; one right-side panel is open at a time, without persisting the collapses that rule forces. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely. Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the active thrust ring is visible; stage-recovery handoff uses a pulsing dot. ## Tooling Snapshot diff --git a/docs/UI-OWNERSHIP.md b/docs/UI-OWNERSHIP.md index 8d24215c7..4132c4779 100644 --- a/docs/UI-OWNERSHIP.md +++ b/docs/UI-OWNERSHIP.md @@ -11,6 +11,7 @@ scene playback and voice. It delegates; new behavior belongs with its owner. | `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal | | `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime | | `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration | +| `panelNavigation.js` | Desktop panel rails: each side's offered panels and the open one | | `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls | | `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners | | `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime | diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index f5846c99c..e86562699 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -45,14 +45,13 @@ They contain no application state. ## Phases 1. **Foundation.** Vue, Pinia and the Vite Vue plugin join the browser build - (`build/vite.js`, so host applications receive them too). One Vue app is - mounted beside the existing markup with a shared Pinia instance. Stores - mirror the state that owners already publish: panel disclosure, the UI - theme, exclusive surfaces (Cockpit, scene playback, recording, clean view), - share state and location. The existing `gev:*` window events feed them. - Nothing visible changes. -2. **Rail.** The panel store becomes the authority for which panel is open on - each side, enforcing one panel per side, and the rail renders from it. + (`build/vite.js`, so host applications receive them too). One Vue runtime is + created by the shell (`src/ui/vueRuntime.js`) with one Pinia instance that + every view shares; disposing the shell unmounts them. Stores are added + when a view needs one, mirroring state an owner already publishes, so + there is never a second owner of the same state. +2. **Rail.** `PanelChrome` enforces one panel per side; the panel store + mirrors each side's panels and the rail renders from it. Done. 3. **Panels.** Each template and its wiring become a component, keeping ids, classes, `data-i18n` text and CSS. Order: Scenes, Weather, Recent imagery, Street level, CCTV, Layers, Display, Context and Radio, the command dock, @@ -67,7 +66,9 @@ They contain no application state. - Unit tests: stores and components run under `npm test`. Single-file components are compiled by a test loader; component tests render with - `@vue/test-utils` and `happy-dom`. + `@vue/test-utils` and `happy-dom`. `npm test` registers the loader; to run + one file that reaches a component (including anything importing the + shell), use `node --import=./src/testSupport/registerVue.mjs --test `. - Visual parity: before/after screenshots of every panel at desktop and phone widths, in the default and Cyber themes, with the rail as the only expected difference. diff --git a/index.html b/index.html index 26cf734fb..373bbdd3a 100644 --- a/index.html +++ b/index.html @@ -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. --> - + diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 28627e4ac..e019efe06 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -1,6 +1,7 @@ import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import vue from '@vitejs/plugin-vue'; import { build, normalizePath } from 'vite'; /** Build each browser or Node export group and reject imports outside its declared ownership. */ @@ -99,15 +100,20 @@ export async function checkPackageBoundaries(root) { ssr: node ? { noExternal: true } : undefined, logLevel: 'silent', plugins: [ + vue(), { name: 'check-package-ownership', moduleParsed(info) { - if (!allowed.has(info.id)) { + // Plugin helpers are virtual; a single-file component's style and + // template blocks belong to the component file itself. + if (info.id.startsWith('\0')) return; + const id = info.id.split('?')[0]; + if (!allowed.has(id)) { throw new Error( - `Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`, + `Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`, ); } - seen.add(info.id); + seen.add(id); }, }, ], diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6197aa43c..7e20adca7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1122,5 +1122,7 @@ "src/ui/kit/UiRail.test.mjs", "src/testSupport/vueDom.mjs", "src/testSupport/vueLoader.mjs", - "src/testSupport/registerVue.mjs" + "src/testSupport/registerVue.mjs", + "src/ui/panelNavigation.test.mjs", + "src/ui/stores/panels.test.mjs" ] diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index c035c6bb2..d19dc6067 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -433,6 +433,7 @@ function sourceFiles() { 'src/*.mjs', 'src/**/*.js', 'src/**/*.mjs', + 'src/**/*.vue', ).filter( (file) => !file.startsWith('src/i18n/') && diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 29476fb6f..e74c561e8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -876,9 +876,18 @@ "src/ui/statusBadge.js", "src/scopeMaskPolicy.js", "src/visualStylePolicy.js", + "src/ui/stores/panels.js", + "src/ui/panelRailCatalog.js", + "src/ui/vueRuntime.js", + "src/ui/kit/UiIcon.vue", + "src/ui/kit/rail.js", + "src/ui/kit/UiTooltip.vue", + "src/ui/kit/UiRail.vue", + "src/ui/PanelRailView.vue", + "src/ui/panelNavigation.js", "src/ui/panelAccordion.js" ], - "external": ["cesium"] + "external": ["cesium", "vue", "pinia"] }, "ui-styles": { "exports": ["./ui/styles"], @@ -2813,6 +2822,15 @@ "src/sceneCommandRelease.js", "src/scopeMaskPolicy.js", "src/visualStylePolicy.js", + "src/ui/stores/panels.js", + "src/ui/panelRailCatalog.js", + "src/ui/vueRuntime.js", + "src/ui/kit/UiIcon.vue", + "src/ui/kit/UiTooltip.vue", + "src/ui/kit/rail.js", + "src/ui/kit/UiRail.vue", + "src/ui/PanelRailView.vue", + "src/ui/panelNavigation.js", "src/ui/panelAccordion.js" ], "external": [ @@ -2824,7 +2842,9 @@ "pbf", "satellite.js", "mapillary-js", - "@jtarrio/webrtlsdr" + "@jtarrio/webrtlsdr", + "vue", + "pinia" ] }, "application-catalog": { @@ -3607,6 +3627,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/panelRailCatalog.js", "src/ui/panelAccordion.js" ], "external": [] diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 938fb20bc..9bf61dd27 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2943,5 +2943,7 @@ "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" + "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen", + "Left panels": "Linke Panels", + "Right panels": "Rechte Panels" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5b893a0cb..f579e2af4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3013,5 +3013,7 @@ "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" + "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos", + "Left panels": "Paneles izquierdos", + "Right panels": "Paneles derechos" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 895b121eb..0d63ef1da 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3013,5 +3013,7 @@ "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" + "Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources", + "Left panels": "Panneaux de gauche", + "Right panels": "Panneaux de droite" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index ccce863d4..864f1f56a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3013,5 +3013,7 @@ "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" + "Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse", + "Left panels": "Pannelli a sinistra", + "Right panels": "Pannelli a destra" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index edd9c079f..5eea0a9fe 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2873,5 +2873,7 @@ "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またはアセットバンドルとしてダウンロード" + "Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード", + "Left panels": "左パネル", + "Right panels": "右パネル" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c3f4bcf90..69aab858a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3013,5 +3013,7 @@ "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" + "Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos", + "Left panels": "Painéis à esquerda", + "Right panels": "Painéis à direita" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index fe8338c3f..e7ef05f5b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3083,5 +3083,7 @@ "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 или архивом ресурсов" + "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов", + "Left panels": "Левые панели", + "Right panels": "Правые панели" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 37e55b961..f9067a2b8 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3083,5 +3083,7 @@ "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 або архівом ресурсів" + "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів", + "Left panels": "Ліві панелі", + "Right panels": "Праві панелі" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8dc28b308..6dab5ce52 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2873,5 +2873,7 @@ "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 或资源包" + "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包", + "Left panels": "左侧面板", + "Right panels": "右侧面板" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c82131223..cc57752c7 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2873,5 +2873,7 @@ "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 或資源包" + "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", + "Left panels": "左側面板", + "Right panels": "右側面板" } diff --git a/src/ui/PanelRailView.vue b/src/ui/PanelRailView.vue new file mode 100644 index 000000000..8f43dabb2 --- /dev/null +++ b/src/ui/PanelRailView.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 140073e9e..cbbd0a7bd 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -51,6 +51,8 @@ import { t, } from '../i18n/index.js'; import { applyLocaleChoice, mountLocalePicker } from './localePicker.js'; +import { PanelNavigation } from './panelNavigation.js'; +import { createUiRuntime } from './vueRuntime.js'; /** * Central UI orchestrator for the God's Eye View application. @@ -133,6 +135,8 @@ export class StyleManager extends ShellFacade { readSharedCollapsed: (panelId) => this._mobileShell?.sharedCollapsed(panelId), }); + // Vue views share this runtime and its stores; dispose unmounts them. + this._ui = createUiRuntime(); this._feedback = new ShellFeedback({ readLayers: () => this._dataManager?.getAll?.() || [], }); @@ -594,6 +598,13 @@ export class StyleManager extends ShellFacade { // Headers first: panel chrome builds its toggle labels from them. this._applyRuntimeStaticHeaderText(); this._initPanelChrome(); + this._panelNavigation = new PanelNavigation({ + ui: this._ui, + stacks: { left: this._leftPanelStack, right: this._rightPanelStack }, + setPanelCollapsed: (...args) => + this._panelChrome.setPanelCollapsed(...args), + }); + this._panelNavigation.init(); this._initLocaleSelector(); this._sizeDisplayLabelColumn(); this._initLeftPanelAdaptiveLayout(); @@ -1776,6 +1787,8 @@ export class StyleManager extends ShellFacade { this._localePicker = null; this._recording.destroy(); this._mobileShell?.destroy(); + this._panelNavigation?.destroy(); + this._ui.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index 0c2a2ad3a..9d9057fa0 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -1,6 +1,7 @@ /** Own rail measurement, layout scheduling and dock tray observation. */ import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js'; import { syncRadioPanelPlacement } from './radioPanelPlacement.js'; +import { isPanelRailMutation } from './panelRailCatalog.js'; const COCKPIT_LAYOUT_SETTLE_MS = 240; /** * Fixed UI regions that can occupy the left accordion's vertical lane. @@ -233,7 +234,8 @@ export class PanelLayoutController { } if (typeof MutationObserver !== 'undefined') { - this._rightStackMutationObserver = new MutationObserver(() => { + this._rightStackMutationObserver = new MutationObserver((records) => { + if (records.every(isPanelRailMutation)) return; this._scheduleRightPanelLayout(); }); this._rightStackMutationObserver.observe(stack, { @@ -334,7 +336,8 @@ export class PanelLayoutController { } if (typeof MutationObserver !== 'undefined') { - this._leftStackMutationObserver = new MutationObserver(() => { + this._leftStackMutationObserver = new MutationObserver((records) => { + if (records.every(isPanelRailMutation)) return; this._scheduleLeftPanelLayout(); }); this._leftStackMutationObserver.observe(stack, { diff --git a/src/ui/panelNavigation.js b/src/ui/panelNavigation.js new file mode 100644 index 000000000..5ef84c53a --- /dev/null +++ b/src/ui/panelNavigation.js @@ -0,0 +1,123 @@ +import PanelRailView from './PanelRailView.vue'; +import { PANEL_RAIL_HOST_CLASS, PANEL_RAIL_ITEMS } from './panelRailCatalog.js'; +import { usePanelStore } from './stores/panels.js'; + +/** + * Own the desktop panel rails: publish each side's panels to the panel store + * and open or close a panel when its rail item is selected. Disclosure itself + * stays with PanelChrome, which enforces one open panel per side. + */ +export class PanelNavigation { + /** + * @param {object} options + * @param {{pinia: object, mount: Function}} options.ui Shell Vue runtime. + * @param {{left: HTMLElement|null, right: HTMLElement|null}} options.stacks Side panel stacks. + * @param {Function} options.setPanelCollapsed PanelChrome's disclosure operation. + */ + constructor({ ui, stacks, setPanelCollapsed }) { + this._ui = ui; + this._stacks = stacks; + this._setPanelCollapsed = setPanelCollapsed; + this._store = usePanelStore(ui.pinia); + this._unmounts = []; + this._hosts = []; + this._observer = null; + this._destroyed = false; + } + + init() { + if (this._destroyed) return; + for (const side of ['left', 'right']) { + const stack = this._stacks[side]; + if (!stack) continue; + // Inside the stack, the rail shows and hides with its panels in every + // presentation mode (Cockpit, clean view, recording, phone sheets). + const host = stack.ownerDocument.createElement('div'); + host.className = `${PANEL_RAIL_HOST_CLASS} ${PANEL_RAIL_HOST_CLASS}--${side}`; + stack.prepend(host); + this._hosts.push(host); + this._unmounts.push( + this._ui.mount(PanelRailView, host, { + side, + onSelect: (panelId) => this.select(panelId), + }), + ); + } + if (typeof MutationObserver !== 'undefined') { + // Only the panels themselves matter: their order, disclosure and + // availability, not changes inside them or inside the rails. + this._observer = new MutationObserver((records) => { + if (records.some((record) => this._isPanelRecord(record))) this.sync(); + }); + for (const stack of Object.values(this._stacks)) { + if (!stack) continue; + this._observer.observe(stack, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'hidden'], + }); + } + } + this.sync(); + } + + /** Publish both sides' panels in display order. */ + sync() { + if (this._destroyed) return; + for (const side of ['left', 'right']) { + const stack = this._stacks[side]; + const panels = stack + ? [...stack.children] + .filter((panel) => panel.matches('[data-panel-id]')) + .map((panel) => { + const shownBy = PANEL_RAIL_ITEMS[panel.id]?.shownBy; + return { + id: panel.id, + title: + panel + .querySelector('.panel-title, .pp-header-label') + ?.textContent.trim() || panel.id, + open: !panel.classList.contains('collapsed'), + available: + !panel.hidden && + (!shownBy || panel.classList.contains(shownBy)), + }; + }) + : []; + this._store.setSide(side, panels); + } + } + + /** + * Toggle a panel from its rail item. + * @param {string} panelId Panel element id. + */ + select(panelId) { + if (this._destroyed) return; + this._setPanelCollapsed(panelId, this._store.isOpen(panelId), { + explicit: true, + }); + } + + _isPanelRecord(record) { + const stacks = Object.values(this._stacks); + return record.type === 'childList' + ? stacks.includes(record.target) && + [...record.addedNodes, ...record.removedNodes].some( + (node) => !this._hosts.includes(node), + ) + : stacks.includes(record.target.parentElement); + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + this._observer?.disconnect(); + this._observer = null; + for (const unmount of this._unmounts) unmount(); + this._unmounts = []; + for (const host of this._hosts) host.remove(); + this._hosts = []; + } +} diff --git a/src/ui/panelNavigation.test.mjs b/src/ui/panelNavigation.test.mjs new file mode 100644 index 000000000..ec6cc8882 --- /dev/null +++ b/src/ui/panelNavigation.test.mjs @@ -0,0 +1,140 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../testSupport/vueDom.mjs'; + +let PanelNavigation; +let createUiRuntime; +let nextTick; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ nextTick } = await import('vue')); + ({ PanelNavigation } = await import('./panelNavigation.js')); + ({ createUiRuntime } = await import('./vueRuntime.js')); +}); +after(() => uninstall()); + +function panel(id, { collapsed = true, hidden = false, title = '' } = {}) { + const element = document.createElement('div'); + element.id = id; + element.dataset.panelId = id; + element.className = collapsed + ? 'panel-collapsible collapsed' + : 'panel-collapsible'; + // Data Layers shows only while `active` (the `f` shortcut toggles it). + if (id === 'data-panel') element.classList.add('active'); + element.hidden = hidden; + if (title) element.innerHTML = `${title}`; + return element; +} + +function setup() { + document.body.innerHTML = ''; + const left = document.createElement('div'); + left.id = 'left-panel-stack'; + left.append(panel('data-panel', { collapsed: false }), panel('scene-panel')); + const right = document.createElement('div'); + right.id = 'right-context-rail'; + right.append( + panel('pp-toggles'), + panel('weather-panel', { hidden: true }), + panel('global-context-panel'), + panel('host-panel', { title: 'HOST' }), + ); + document.body.append(left, right); + const calls = []; + const ui = createUiRuntime(); + const navigation = new PanelNavigation({ + ui, + stacks: { left, right }, + setPanelCollapsed: (...args) => calls.push(args), + }); + navigation.init(); + return { left, right, calls, ui, navigation }; +} + +const railItems = (stack) => + [...stack.querySelectorAll('.gev-panel-rail [data-rail-item]')].map( + (button) => [button.dataset.railItem, button.getAttribute('aria-pressed')], + ); + +test('each stack gets a rail of its offered panels, marking the open one', async () => { + const { left, right, ui, navigation } = setup(); + await nextTick(); + assert.equal( + left.firstElementChild.className, + 'gev-panel-rail gev-panel-rail--left', + ); + assert.deepEqual(railItems(left), [ + ['data-panel', 'true'], + ['scene-panel', 'false'], + ]); + // Hidden panels are not offered; a host's panel gets a generic item. + assert.deepEqual(railItems(right), [ + ['pp-toggles', 'false'], + ['global-context-panel', 'false'], + ['host-panel', 'false'], + ]); + assert.equal( + right + .querySelector('[data-rail-item="host-panel"]') + .getAttribute('aria-label'), + 'HOST', + ); + assert.equal( + left.querySelector('nav').getAttribute('aria-label'), + 'Left panels', + ); + navigation.destroy(); + ui.destroy(); +}); + +test('selecting an item toggles its panel explicitly', async () => { + const { left, calls, ui, navigation } = setup(); + await nextTick(); + left.querySelector('[data-rail-item="scene-panel"]').click(); + left.querySelector('[data-rail-item="data-panel"]').click(); + assert.deepEqual(calls, [ + ['scene-panel', false, { explicit: true }], + ['data-panel', true, { explicit: true }], + ]); + navigation.destroy(); + ui.destroy(); +}); + +test('the rails follow disclosure and availability changes', async () => { + const { left, right, ui, navigation } = setup(); + document.getElementById('data-panel').classList.add('collapsed'); + document.getElementById('scene-panel').classList.remove('collapsed'); + document.getElementById('weather-panel').hidden = false; + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + assert.deepEqual(railItems(left), [ + ['data-panel', 'false'], + ['scene-panel', 'true'], + ]); + assert.deepEqual(railItems(right), [ + ['pp-toggles', 'false'], + ['weather-panel', 'false'], + ['global-context-panel', 'false'], + ['host-panel', 'false'], + ]); + // `f` hides Data Layers outright, so the rail stops offering it. + document.getElementById('data-panel').classList.remove('active'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + assert.deepEqual(railItems(left), [['scene-panel', 'true']]); + navigation.destroy(); + ui.destroy(); +}); + +test('destroy removes the rails and stops following the stacks', async () => { + const { left, right, calls, ui, navigation } = setup(); + navigation.destroy(); + navigation.destroy(); + assert.equal(left.querySelector('.gev-panel-rail'), null); + assert.equal(right.querySelector('.gev-panel-rail'), null); + navigation.select('data-panel'); + assert.deepEqual(calls, []); + ui.destroy(); +}); diff --git a/src/ui/panelRailCatalog.js b/src/ui/panelRailCatalog.js new file mode 100644 index 000000000..e6613d001 --- /dev/null +++ b/src/ui/panelRailCatalog.js @@ -0,0 +1,41 @@ +import { msg } from '../i18n/index.js'; + +/** Class of the element each side stack hosts its rail in. */ +export const PANEL_RAIL_HOST_CLASS = 'gev-panel-rail'; + +/** Glyph for a panel without a catalog entry, such as one a host adds. */ +export const PANEL_RAIL_FALLBACK_ICON = 'more_horiz'; + +/** + * How each side panel appears on its rail. Labels are the panels' own titles. + * `shownBy` names a class the panel needs to be shown at all (`f` toggles + * Data Layers' `active`). + */ +export const PANEL_RAIL_ITEMS = Object.freeze({ + 'data-panel': { + icon: 'layers', + label: msg('DATA LAYERS'), + shownBy: 'active', + }, + 'scene-panel': { icon: 'movie', label: msg('AUTHORING') }, + 'pp-toggles': { icon: 'tune', label: msg('DISPLAY') }, + 'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, + 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, + 'recent-imagery-panel': { + icon: 'satellite_alt', + label: msg('RECENT IMAGERY'), + }, + 'street-level-panel': { icon: 'streetview', label: msg('STREET LEVEL') }, + 'global-context-panel': { icon: 'radar', label: msg('CONTEXT') }, +}); + +/** + * Whether a mutation happened inside a panel rail, which no stack layout or + * panel state depends on. + * @param {MutationRecord} record Observed mutation. + */ +export function isPanelRailMutation(record) { + const target = + record.target.nodeType === 1 ? record.target : record.target.parentElement; + return Boolean(target?.closest?.(`.${PANEL_RAIL_HOST_CLASS}`)); +} diff --git a/src/ui/stores/panels.js b/src/ui/stores/panels.js new file mode 100644 index 000000000..2fc5d2ac3 --- /dev/null +++ b/src/ui/stores/panels.js @@ -0,0 +1,57 @@ +import { defineStore } from 'pinia'; +import { reactive } from 'vue'; + +/** + * @typedef {object} RailPanel + * @property {string} id Panel element id. + * @property {string} title The panel's translated title. + * @property {boolean} open Whether the panel is expanded. + * @property {boolean} available Whether the panel is offered (not `hidden`). + */ + +const samePanels = (a, b) => + a.length === b.length && + a.every( + (panel, index) => + panel.id === b[index].id && + panel.title === b[index].title && + panel.open === b[index].open && + panel.available === b[index].available, + ); + +/** + * The desktop side panels as the rails present them. Panel disclosure stays + * with PanelChrome; this store publishes each side's panels in order. + */ +export const usePanelStore = defineStore('gev-panels', () => { + /** @type {{left: RailPanel[], right: RailPanel[]}} */ + const sides = reactive({ left: [], right: [] }); + + /** + * Replace one side's panels, keeping the array when nothing changed. + * @param {'left'|'right'} side Sidebar. + * @param {RailPanel[]} panels Panels in display order. + */ + function setSide(side, panels) { + if (!samePanels(sides[side], panels)) sides[side] = panels; + } + + /** + * @param {'left'|'right'} side Sidebar. + * @returns {string|null} The open panel's id. + */ + function openPanelId(side) { + return ( + sides[side].find((panel) => panel.open && panel.available)?.id ?? null + ); + } + + /** @param {string} id Panel id. */ + function isOpen(id) { + return [...sides.left, ...sides.right].some( + (panel) => panel.id === id && panel.open, + ); + } + + return { sides, setSide, openPanelId, isOpen }; +}); diff --git a/src/ui/stores/panels.test.mjs b/src/ui/stores/panels.test.mjs new file mode 100644 index 000000000..87ec34094 --- /dev/null +++ b/src/ui/stores/panels.test.mjs @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { usePanelStore } from './panels.js'; + +test('the panel store reports each side’s open panel and keeps unchanged sides', () => { + const store = usePanelStore(createPinia()); + const left = [ + { id: 'data-panel', open: false, available: true }, + { id: 'scene-panel', open: true, available: true }, + ]; + store.setSide('left', left); + store.setSide('right', [ + { id: 'weather-panel', open: true, available: false }, + ]); + assert.equal(store.openPanelId('left'), 'scene-panel'); + // An open panel that is not offered is not the side's open panel. + assert.equal(store.openPanelId('right'), null); + assert.equal(store.isOpen('scene-panel'), true); + assert.equal(store.isOpen('data-panel'), false); + const published = store.sides.left; + store.setSide( + 'left', + left.map((panel) => ({ ...panel })), + ); + assert.equal(store.sides.left, published); +}); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index e71cd381c..13694b20d 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -263,6 +263,33 @@ #left-panel-stack.layout-exclusive > [data-panel-id].collapsed { display: none; } + +/* Panel rails (src/ui/panelNavigation.js). Each rail lives inside its stack, + so it shows and hides with the stack, and floats in the screen-edge gutter. */ +.gev-panel-rail { + position: absolute; + top: 0; + z-index: 1; + pointer-events: auto; +} +.gev-panel-rail--left { + right: calc(100% + 6px); +} +.gev-panel-rail--right { + left: calc(100% + 6px); +} +body.mobile-shell #left-panel-stack > .gev-panel-rail, +body.mobile-shell #right-context-rail > .gev-panel-rail, +body.cockpit-mode .gev-panel-rail { + display: none; +} +/* Clean view and recording hide the panels one by one, not their stacks. */ +body.ui-clean-view .gev-panel-rail, +body.recording-mode .gev-panel-rail { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} #right-context-rail.layout-focus > [data-panel-id].collapsed { display: block; flex: 0 0 auto; diff --git a/src/ui/vueRuntime.js b/src/ui/vueRuntime.js new file mode 100644 index 000000000..1a6ea0091 --- /dev/null +++ b/src/ui/vueRuntime.js @@ -0,0 +1,36 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; + +/** + * Create the application's Vue runtime: one Pinia instance shared by every + * view the shell mounts, and the mounts themselves for teardown. + * @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}} + */ +export function createUiRuntime() { + const pinia = createPinia(); + const apps = new Set(); + return { + pinia, + /** + * Mount a root component into a host element. + * @param {object} component Root component. + * @param {Element} host Element the view renders into. + * @param {object} [props] Root props. + * @returns {Function} Unmount this view. + */ + mount(component, host, props = {}) { + const app = createApp(component, props); + app.use(pinia); + app.mount(host); + apps.add(app); + return () => { + if (!apps.delete(app)) return; + app.unmount(); + }; + }, + destroy() { + for (const app of apps) app.unmount(); + apps.clear(); + }, + }; +}