diff --git a/index.html b/index.html index 803bc2417..b563cf718 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/package-boundaries.json b/scripts/package-boundaries.json index 00f369e40..389adde77 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -146,7 +146,10 @@ "src/ui/stores/recording.js", "src/ui/styleParameters.js", "src/ui/i18n.js", - "src/time/worldClock.js" + "src/time/worldClock.js", + "src/ui/kit/UiIcon.vue", + "src/ui/stores/worldTime.js", + "src/ui/views/panels/WorldTimeline.vue" ], "external": ["vue", "pinia", "vue-i18n"] }, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 3dd0b6d73..69ae84544 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3218,5 +3218,8 @@ "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 أو حزمة موارد", + "World time": "وقت العالم", + "Back to live": "العودة إلى البث المباشر", + "Live": "مباشر" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 144617819..da1bebb4a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2938,5 +2938,8 @@ "Left panels": "Linke Panels", "Right panels": "Rechte Panels", "CONFIRMED": "BESTÄTIGT", - "PAD / RTLS": "STARTPLATZ / RTLS" + "PAD / RTLS": "STARTPLATZ / RTLS", + "World time": "Weltzeit", + "Back to live": "Zurück zu live", + "Live": "Live" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e1d21a5c7..f592a72c5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,5 +3008,8 @@ "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", "Left panels": "Paneles izquierdos", - "Right panels": "Paneles derechos" + "Right panels": "Paneles derechos", + "World time": "Hora mundial", + "Back to live": "Volver al directo", + "Live": "En directo" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4a6987fad..4d7289ff7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3008,5 +3008,8 @@ "Left panels": "Panneaux de gauche", "Right panels": "Panneaux de droite", "CONFIRMED": "CONFIRMÉE", - "PAD / RTLS": "PAS DE TIR / RTLS" + "PAD / RTLS": "PAS DE TIR / RTLS", + "World time": "Heure du monde", + "Back to live": "Revenir au direct", + "Live": "Direct" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 1cf792029..065137689 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3008,5 +3008,8 @@ "Left panels": "Pannelli a sinistra", "Right panels": "Pannelli a destra", "CONFIRMED": "CONFERMATA", - "PAD / RTLS": "SITO DI LANCIO / RTLS" + "PAD / RTLS": "SITO DI LANCIO / RTLS", + "World time": "Ora del mondo", + "Back to live": "Torna alla diretta", + "Live": "Diretta" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index df96321e3..3ff743076 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2868,5 +2868,8 @@ "Left panels": "左パネル", "Right panels": "右パネル", "CONFIRMED": "確認済み", - "PAD / RTLS": "射場 / RTLS" + "PAD / RTLS": "射場 / RTLS", + "World time": "世界時刻", + "Back to live": "ライブに戻る", + "Live": "ライブ" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index b4326fc74..455515ab2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3008,5 +3008,8 @@ "Left panels": "Painéis à esquerda", "Right panels": "Painéis à direita", "CONFIRMED": "CONFIRMADA", - "PAD / RTLS": "PLATAFORMA / RTLS" + "PAD / RTLS": "PLATAFORMA / RTLS", + "World time": "Hora do mundo", + "Back to live": "Voltar ao vivo", + "Live": "Ao vivo" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7bd1ea870..90099d66c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3078,5 +3078,8 @@ "Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров", "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов", "Left panels": "Левые панели", - "Right panels": "Правые панели" + "Right panels": "Правые панели", + "World time": "Мировое время", + "Back to live": "Вернуться к прямому эфиру", + "Live": "Сейчас" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index fbe8a6d3f..6ca398f07 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3078,5 +3078,8 @@ "Left panels": "Ліві панелі", "Right panels": "Праві панелі", "CONFIRMED": "ПІДТВЕРДЖЕНО", - "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS" + "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS", + "World time": "Світовий час", + "Back to live": "Повернутися до прямого ефіру", + "Live": "Зараз" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ffdd8a4b4..019e97701 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2868,5 +2868,8 @@ "Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情", "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包", "Left panels": "左侧面板", - "Right panels": "右侧面板" + "Right panels": "右侧面板", + "World time": "世界时间", + "Back to live": "返回实时", + "Live": "实时" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c7b0bb5eb..3e41803e6 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2868,5 +2868,8 @@ "Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情", "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", "Left panels": "左側面板", - "Right panels": "右側面板" + "Right panels": "右側面板", + "World time": "世界時間", + "Back to live": "返回即時", + "Live": "即時" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 1cbf64140..a4edfe8d6 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -88,6 +88,7 @@ "Landsat 8/9", "Latin", "Layout", + "Live", "London", "Los Angeles", "MIL", diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index dddedf575..ee0d5541b 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#gev-voice-control', '#pp-toggles', '#param-slider-panel', + '#world-timeline', ].join(', '); /** * Fixed UI regions that can occupy the right control lane. Runtime rectangle @@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [ '#cesium-credits .cesium-credit-textContainer', '#command-dock', '#gev-voice-control', + '#world-timeline', ].join(', '); export class PanelLayoutController { constructor({ diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js new file mode 100644 index 000000000..8169982f3 --- /dev/null +++ b/src/ui/stores/worldTime.js @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia'; +import { onScopeDispose, shallowRef } from 'vue'; +import { worldClock } from '../../time/worldClock.js'; + +/** + * The world clock as the timeline shows it: `{live, playing, timeMs}`. It + * follows the page's world clock, which tells it of every move, and of the + * time a few times a second while it plays on its own. + */ +export const useWorldTimeStore = defineStore('gev-world-time', () => { + const read = (state = worldClock.getState()) => + Object.freeze({ + live: state.live, + playing: state.playing, + timeMs: state.timeMs, + }); + const time = shallowRef(read()); + onScopeDispose( + worldClock.subscribe((state) => { + time.value = read(state); + }), + ); + + return { + time, + togglePlay: () => worldClock.togglePlay(), + goLive: () => worldClock.goLive(), + }; +}); diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css new file mode 100644 index 000000000..b791b9520 --- /dev/null +++ b/src/ui/styles/world-timeline.css @@ -0,0 +1,62 @@ +/* ── World timeline ─────────────────────────── */ +/* Shown while the world time is away from live, centred above the dock. */ +#world-timeline { + position: fixed; + bottom: calc(2vh + 7rem); + left: 50%; + z-index: 146; + display: flex; + align-items: center; + gap: 10px; + max-width: calc(100vw - 32px); + box-sizing: border-box; + padding: 4px 6px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: var(--glass-bg); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + transform: translateX(-50%); +} + +body.ui-clean-view #world-timeline, +body.recording-mode #world-timeline { + bottom: 2vh; +} + +#world-timeline .world-timeline-play, +#world-timeline .world-timeline-live { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid rgba(123, 189, 211, 0.28); + border-radius: 8px; + background: rgba(0, 212, 255, 0.08); + color: var(--text-primary); + font: inherit; + cursor: pointer; +} + +#world-timeline .world-timeline-play:hover, +#world-timeline .world-timeline-live:hover { + background: rgba(0, 212, 255, 0.18); +} + +#world-timeline .world-timeline-live { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +#world-timeline .world-timeline-time { + overflow: hidden; + font-family: var(--font-mono); + font-size: 13px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index f77dad3d2..32900f42a 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue'; import UiButton from '../kit/UiButton.vue'; import GlobeActions from './panels/GlobeActions.vue'; +import WorldTimeline from './panels/WorldTimeline.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -117,4 +118,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs new file mode 100644 index 000000000..0fc35c5c2 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -0,0 +1,45 @@ +// The world timeline shows the world time while it is away from live, with +// play and pause on the left and the way back to live on the right. +import '../../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { worldClock } from '../../../time/worldClock.js'; +import WorldTimeline from './WorldTimeline.vue'; + +test('the timeline shows away from live and plays, pauses and goes back', async (t) => { + document.body.replaceChildren(); + const app = createApp(WorldTimeline).use(createPinia()); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => { + app.unmount(); + worldClock.goLive(); + }); + const bar = () => document.getElementById('world-timeline'); + await nextTick(); + assert.equal(bar(), null, 'hidden while live'); + + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + worldClock.seek(at); + await nextTick(); + assert.ok(bar()); + const time = bar().querySelector('time'); + assert.equal(time.getAttribute('datetime'), new Date(at).toISOString()); + assert.match(time.textContent, /UTC$/); + const play = bar().querySelector('.world-timeline-play'); + assert.equal(play.getAttribute('aria-label'), 'Play'); + + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(play.getAttribute('aria-label'), 'Pause'); + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), false); + + bar().querySelector('.world-timeline-live').click(); + await nextTick(); + assert.equal(worldClock.isLive(), true); + assert.equal(bar(), null); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue new file mode 100644 index 000000000..33026f2a1 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.vue @@ -0,0 +1,34 @@ + + + diff --git a/style.css b/style.css index 7229ab02f..1def60bb3 100644 --- a/style.css +++ b/style.css @@ -29,6 +29,7 @@ @import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/locale-picker.css'; @import './src/ui/styles/weather.css'; +@import './src/ui/styles/world-timeline.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/kit.css';