diff --git a/.env.example b/.env.example
index 2a54795..0c87f6c 100644
--- a/.env.example
+++ b/.env.example
@@ -129,6 +129,10 @@ AISSTREAM_API_KEY=
# tiles instead of hitting upstream.
# TOMTOM_DAILY_TILE_BUDGET=40000
+# Optional: Steam Deck / gamepad controls default ON. Set to 0 to default off;
+# DISPLAY → Gamepad still overrides via localStorage.
+# GEV_DECK_CONTROLS=1
+
# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset
# unless you're customizing the camera source pack.
# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21544fa..5a499ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
+### Added
+
+- Steam Deck / gamepad camera controls (`src/deckControls.js`): sticks for
+ move/look, triggers for zoom, face buttons and D-pad for common UI actions,
+ optional DISPLAY → Gamepad slider. Defaults on unless `GEV_DECK_CONTROLS=0`;
+ the slider preference is stored in `localStorage`.
+
### Fixed
- Mapped-site outages show their scheduled retry countdown and distinguish
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 1f4d861..c7d96ca 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -2318,7 +2318,7 @@ silently demoting every later lookup for the session.
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `` on press.
- 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).
+- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI, Gamepad (HUD + DETECT promoted to the top). Gamepad controls (`src/deckControls.js`) default on unless `GEV_DECK_CONTROLS=0`; DISPLAY → Gamepad persists the choice in `localStorage` (`gev-deck-controls`). When a pad is connected and enabled, a center reticle appears, look sensitivity scales down near the ground, and R3 toggles an on-screen mapping help overlay. 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.
- `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.
diff --git a/index.html b/index.html
index e8b0b8d..1d80993 100644
--- a/index.html
+++ b/index.html
@@ -454,6 +454,19 @@
□
Clean UI
+
+
+ ◉
+ Gamepad
+
+
+ On
+
+
+
+
+
+
✨
diff --git a/src/deckControls.js b/src/deckControls.js
new file mode 100644
index 0000000..9cb7821
--- /dev/null
+++ b/src/deckControls.js
@@ -0,0 +1,485 @@
+/**
+ * Steam Deck / gamepad controls for God's Eye View.
+ *
+ * Left stick — move (forward / back / strafe)
+ * Right stick — look (rotate / tilt), sensitivity scales down when zoomed in
+ * L2 / R2 — zoom out / in
+ * L1 — toggle HUD
+ * R1 — toggle data panel
+ * A — select target at screen center
+ * B — back / clear tracking (Escape)
+ * X — toggle CCTV layer
+ * Y — reset globe
+ * D-pad up/down — cycle visual style
+ * D-pad left — toggle orbit
+ * D-pad right — toggle clean view
+ * Select — cycle detection overlay
+ * Start (hold) — push-to-talk voice
+ * R3 — toggle controls help overlay
+ * L3 (stick click) — toggle cockpit (when aircraft tracked)
+ */
+
+import * as Cesium from 'cesium';
+import { interruptCameraMotion } from './cameraVerbs.js';
+import {
+ holdContinuousRender,
+ releaseContinuousRender,
+ governorRequestRender,
+} from './renderGovernor.js';
+
+const DEADZONE = 0.18;
+const STYLES = ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'];
+
+/** localStorage key for the DISPLAY → Gamepad slider. */
+export const DECK_CONTROLS_STORAGE_KEY = 'gev-deck-controls';
+
+export function readDeckControlsEnabled(storage = globalThis.localStorage, envFlag = import.meta.env?.GEV_DECK_CONTROLS) {
+ try {
+ const stored = storage?.getItem?.(DECK_CONTROLS_STORAGE_KEY);
+ if (stored === '1') return true;
+ if (stored === '0') return false;
+ } catch {
+ // private mode / missing storage
+ }
+ return envFlag !== '0';
+}
+
+export function writeDeckControlsEnabled(enabled, storage = globalThis.localStorage) {
+ try {
+ storage?.setItem?.(DECK_CONTROLS_STORAGE_KEY, enabled ? '1' : '0');
+ } catch {
+ // ignore quota / privacy errors
+ }
+}
+
+/** Camera altitude (m) below which look input is heavily damped. */
+export const DECK_LOOK_MIN_ALTITUDE_M = 400;
+/** Camera altitude (m) above which look input reaches full strength. */
+export const DECK_LOOK_FULL_ALTITUDE_M = 120_000;
+/** Look sensitivity at low altitude as a fraction of the global baseline. */
+export const DECK_LOOK_MIN_SCALE = 0.06;
+/** Baseline right-stick turn rate (radians/sec at full deflection). */
+export const DECK_LOOK_BASE_RATE = 1.35;
+/** Baseline mouse-look scale when pointer lock is active. */
+export const DECK_MOUSE_LOOK_BASE_SCALE = 0.0032;
+
+const BTN = {
+ A: 0,
+ B: 1,
+ X: 2,
+ Y: 3,
+ L1: 4,
+ R1: 5,
+ L2: 6,
+ R2: 7,
+ SELECT: 8,
+ START: 9,
+ L3: 10,
+ R3: 11,
+ DPAD_UP: 12,
+ DPAD_DOWN: 13,
+ DPAD_LEFT: 14,
+ DPAD_RIGHT: 15,
+};
+
+/**
+ * Scale look sensitivity by camera altitude so street-level views stay controllable.
+ * @param {number} heightM
+ * @returns {number} Multiplier in [DECK_LOOK_MIN_SCALE, 1].
+ */
+export function deckLookSensitivityScale(heightM) {
+ const height = Math.max(DECK_LOOK_MIN_ALTITUDE_M, Number(heightM) || DECK_LOOK_MIN_ALTITUDE_M);
+ if (height >= DECK_LOOK_FULL_ALTITUDE_M) return 1;
+ const minLog = Math.log(DECK_LOOK_MIN_ALTITUDE_M);
+ const fullLog = Math.log(DECK_LOOK_FULL_ALTITUDE_M);
+ const progress = (Math.log(height) - minLog) / (fullLog - minLog);
+ const clamped = Math.max(0, Math.min(1, progress));
+ return DECK_LOOK_MIN_SCALE + (1 - DECK_LOOK_MIN_SCALE) * clamped;
+}
+
+function applyDeadzone(value) {
+ const v = Number(value) || 0;
+ if (Math.abs(v) < DEADZONE) return 0;
+ const sign = Math.sign(v);
+ return sign * ((Math.abs(v) - DEADZONE) / (1 - DEADZONE));
+}
+
+function triggerValue(buttons, index) {
+ const btn = buttons[index];
+ if (!btn) return 0;
+ if (typeof btn.value === 'number' && btn.value > 0) return btn.value;
+ return btn.pressed ? 1 : 0;
+}
+
+function buttonPressed(index, buttons, prevButtons) {
+ return Boolean(buttons[index]?.pressed && !prevButtons[index]?.pressed);
+}
+
+function buttonReleased(index, buttons, prevButtons) {
+ return Boolean(!buttons[index]?.pressed && prevButtons[index]?.pressed);
+}
+
+function dispatchKey(key) {
+ document.dispatchEvent(new KeyboardEvent('keydown', {
+ key,
+ bubbles: true,
+ cancelable: true,
+ }));
+}
+
+function simulateCenterClick(viewer) {
+ const canvas = viewer.scene.canvas;
+ const rect = canvas.getBoundingClientRect();
+ const clientX = rect.left + rect.width / 2;
+ const clientY = rect.top + rect.height / 2;
+ const base = {
+ clientX,
+ clientY,
+ pointerId: 9001,
+ pointerType: 'mouse',
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ view: window,
+ };
+ canvas.dispatchEvent(new PointerEvent('pointerdown', { ...base, buttons: 1 }));
+ canvas.dispatchEvent(new PointerEvent('pointerup', { ...base, buttons: 0 }));
+}
+
+function canControlCamera(viewer, styleManager) {
+ const controller = viewer.scene.screenSpaceCameraController;
+ if (!controller?.enableInputs) return false;
+ if (document.body.classList.contains('cockpit-mode')) return false;
+ if (styleManager?.cockpitView?.active) return false;
+ return true;
+}
+
+function isPointerLockedToCanvas(canvas) {
+ return document.pointerLockElement === canvas;
+}
+
+/**
+ * Steam Deck desktop profiles often expose L2/R2 as gamepad buttons but route
+ * the right stick through the mouse cursor instead of axes 2/3. Pointer lock
+ * turns those mouse deltas back into camera look input.
+ */
+function bindPointerLookFallback(canvas, getActive, onDelta) {
+ const onMouseMove = (event) => {
+ if (!getActive() || !isPointerLockedToCanvas(canvas)) return;
+ const dx = Number(event.movementX) || 0;
+ const dy = Number(event.movementY) || 0;
+ if (dx || dy) onDelta(dx, dy);
+ };
+ canvas.addEventListener('mousemove', onMouseMove);
+ return () => canvas.removeEventListener('mousemove', onMouseMove);
+}
+
+function requestCanvasPointerLock(canvas) {
+ if (isPointerLockedToCanvas(canvas)) return;
+ canvas.requestPointerLock?.().catch(() => {
+ // Steam/Chromium may reject until a fresh user gesture; we'll retry on input.
+ });
+}
+
+function createReticle() {
+ const el = document.createElement('div');
+ el.id = 'deck-reticle';
+ el.hidden = true;
+ el.setAttribute('aria-hidden', 'true');
+ el.innerHTML = ' ';
+ document.body.appendChild(el);
+ return el;
+}
+
+function createHelpPanel() {
+ const el = document.createElement('div');
+ el.id = 'deck-controls-help';
+ el.hidden = true;
+ el.setAttribute('aria-label', 'Gamepad controls');
+ el.innerHTML = `
+ DECK CONTROLS
+ L Stick Move
+ R Stick Look
+ L2 / R2 Zoom
+ A Select
+ B Back
+ Y Reset globe
+ D-pad Style / orbit / clean
+ L3 Cockpit
+ L1 / R1 HUD / panel
+ Start (hold) Push to talk
+ R3 Hide this help
+ DISPLAY → Gamepad slider turns this scheme on or off.
+ `;
+ document.body.appendChild(el);
+ return el;
+}
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @param {import('./ui.js').StyleManager} styleManager
+ */
+export function initDeckControls(viewer, styleManager) {
+ const canvas = viewer.scene.canvas;
+ const reticle = createReticle();
+ const helpPanel = createHelpPanel();
+ let connected = false;
+ let enabled = readDeckControlsEnabled();
+ let helpVisible = true;
+ let prevButtons = [];
+ let styleIndex = 0;
+ let cameraActive = false;
+ let lastFrameMs = performance.now();
+ let rafId = 0;
+ let lookMouseDX = 0;
+ let lookMouseDY = 0;
+
+ const releasePointerLook = bindPointerLookFallback(
+ canvas,
+ () => enabled && connected && canControlCamera(viewer, styleManager),
+ (dx, dy) => {
+ lookMouseDX += dx;
+ lookMouseDY += dy;
+ },
+ );
+
+ const syncHelpVisibility = () => {
+ helpPanel.hidden = !enabled || !connected || !helpVisible;
+ document.body.classList.toggle('deck-help-hidden', connected && enabled && !helpVisible);
+ };
+
+ const setConnected = (active) => {
+ connected = active && enabled;
+ document.body.classList.toggle('deck-controls-active', connected);
+ reticle.hidden = !connected;
+ syncHelpVisibility();
+ };
+
+ const syncToggleUi = () => {
+ const toggleBtn = document.getElementById('deck-controls-toggle');
+ const switchEl = document.getElementById('deck-controls-switch');
+ toggleBtn?.classList.toggle('active', enabled);
+ if (toggleBtn) toggleBtn.setAttribute('aria-pressed', String(enabled));
+ if (switchEl) switchEl.checked = enabled;
+ };
+
+ const setEnabled = (next) => {
+ enabled = Boolean(next);
+ writeDeckControlsEnabled(enabled);
+ syncToggleUi();
+ if (!enabled) {
+ endPushToTalk();
+ setConnected(false);
+ prevButtons = [];
+ if (cameraActive) {
+ releaseContinuousRender('deck-controls');
+ cameraActive = false;
+ }
+ if (document.pointerLockElement === canvas) document.exitPointerLock?.();
+ } else {
+ const pads = navigator.getGamepads?.() || [];
+ if (pads.some((pad) => pad?.connected)) {
+ syncStyleIndex();
+ setConnected(true);
+ }
+ }
+ };
+
+ const syncStyleIndex = () => {
+ const current = styleManager?.activeStyle || 'normal';
+ const idx = STYLES.indexOf(current);
+ if (idx >= 0) styleIndex = idx;
+ };
+
+ const cycleStyle = (delta) => {
+ styleIndex = (styleIndex + delta + STYLES.length) % STYLES.length;
+ styleManager?.setStyle?.(STYLES[styleIndex]);
+ };
+
+ const toggleHelpPanel = () => {
+ helpVisible = !helpVisible;
+ syncHelpVisibility();
+ };
+
+ const beginPushToTalk = () => {
+ window.__gevVoiceCommands?.beginPushToTalk?.();
+ };
+
+ const endPushToTalk = () => {
+ window.__gevVoiceCommands?.endPushToTalk?.();
+ };
+
+ const toggleHud = () => {
+ styleManager?.shareLinkManager?.claimRestoreLane?.('visual');
+ styleManager?.hud?.toggle?.();
+ styleManager?._updateHudButtonState?.();
+ styleManager?._syncShareState?.();
+ };
+
+ const updateCamera = (gamepad, dt) => {
+ if (!canControlCamera(viewer, styleManager)) {
+ if (cameraActive) {
+ releaseContinuousRender('deck-controls');
+ cameraActive = false;
+ }
+ return;
+ }
+
+ const camera = viewer.camera;
+ const height = Math.max(100, camera.positionCartographic?.height ?? 1000);
+ const lookScale = deckLookSensitivityScale(height);
+ const moveRate = Math.max(80, Math.min(height * 0.35, 400000)) * dt;
+ const rotateRate = DECK_LOOK_BASE_RATE * lookScale * dt;
+ const mouseLookScale = DECK_MOUSE_LOOK_BASE_SCALE * lookScale;
+ const zoomRate = height * 0.9 * dt;
+
+ const lx = applyDeadzone(gamepad.axes[0]);
+ const ly = applyDeadzone(-gamepad.axes[1]);
+ const rx = applyDeadzone(gamepad.axes[2]);
+ const ry = applyDeadzone(-gamepad.axes[3]);
+ const l2 = triggerValue(gamepad.buttons, BTN.L2);
+ const r2 = triggerValue(gamepad.buttons, BTN.R2);
+
+ const moved = lx || ly || rx || ry || l2 > 0.08 || r2 > 0.08;
+ if (moved) {
+ interruptCameraMotion('manual-input');
+ requestCanvasPointerLock(canvas);
+ }
+
+ if (ly) camera.moveForward(moveRate * ly);
+ if (lx) camera.moveRight(moveRate * lx);
+ if (rx) camera.rotateRight(rotateRate * rx);
+ if (ry) camera.lookUp(rotateRate * ry);
+
+ const hadMouseLook = !rx && !ry && (lookMouseDX || lookMouseDY);
+ if (hadMouseLook) {
+ camera.rotateRight(lookMouseDX * mouseLookScale);
+ camera.lookUp(-lookMouseDY * mouseLookScale);
+ lookMouseDX = 0;
+ lookMouseDY = 0;
+ }
+
+ if (r2 > 0.08) camera.zoomIn(zoomRate * r2);
+ if (l2 > 0.08) camera.zoomOut(zoomRate * l2);
+
+ const cameraChanged = moved || hadMouseLook;
+ if (cameraChanged) {
+ holdContinuousRender('deck-controls');
+ governorRequestRender('deck-controls');
+ cameraActive = true;
+ } else if (cameraActive) {
+ releaseContinuousRender('deck-controls');
+ cameraActive = false;
+ }
+ };
+
+ const handleButtons = (gamepad) => {
+ const { buttons } = gamepad;
+
+ if (buttonPressed(BTN.A, buttons, prevButtons)) {
+ if (!isPointerLockedToCanvas(canvas)) requestCanvasPointerLock(canvas);
+ else simulateCenterClick(viewer);
+ }
+ if (buttonPressed(BTN.B, buttons, prevButtons)) dispatchKey('Escape');
+ if (buttonPressed(BTN.X, buttons, prevButtons)) dispatchKey('c');
+ if (buttonPressed(BTN.Y, buttons, prevButtons)) styleManager?.resetToGlobeView?.();
+
+ if (buttonPressed(BTN.L1, buttons, prevButtons)) toggleHud();
+ if (buttonPressed(BTN.R1, buttons, prevButtons)) dispatchKey('f');
+
+ if (buttonPressed(BTN.SELECT, buttons, prevButtons)) {
+ styleManager?.shareLinkManager?.claimRestoreLane?.('visual');
+ styleManager._detectionUserOverridden = true;
+ dispatchKey('d');
+ }
+
+ if (buttonPressed(BTN.START, buttons, prevButtons)) beginPushToTalk();
+ if (buttonReleased(BTN.START, buttons, prevButtons)) endPushToTalk();
+
+ if (buttonPressed(BTN.L3, buttons, prevButtons)) {
+ const cockpit = styleManager?.cockpitView;
+ if (cockpit?.active) cockpit.exit();
+ else cockpit?.enter?.();
+ }
+ if (buttonPressed(BTN.R3, buttons, prevButtons)) toggleHelpPanel();
+
+ if (buttonPressed(BTN.DPAD_UP, buttons, prevButtons)) cycleStyle(1);
+ if (buttonPressed(BTN.DPAD_DOWN, buttons, prevButtons)) cycleStyle(-1);
+ if (buttonPressed(BTN.DPAD_LEFT, buttons, prevButtons)) dispatchKey('o');
+ if (buttonPressed(BTN.DPAD_RIGHT, buttons, prevButtons)) dispatchKey('v');
+ };
+
+ const poll = () => {
+ const pads = navigator.getGamepads?.() || [];
+ const gamepad = pads.find((pad) => pad?.connected) || null;
+ const now = performance.now();
+ const dt = Math.min(0.05, Math.max(0.001, (now - lastFrameMs) / 1000));
+ lastFrameMs = now;
+
+ if (!enabled || !gamepad) {
+ if (connected) {
+ endPushToTalk();
+ setConnected(false);
+ }
+ prevButtons = [];
+ rafId = requestAnimationFrame(poll);
+ return;
+ }
+
+ if (!connected) {
+ syncStyleIndex();
+ setConnected(true);
+ }
+
+ updateCamera(gamepad, dt);
+ handleButtons(gamepad);
+ prevButtons = gamepad.buttons.map((btn) => ({ pressed: btn.pressed, value: btn.value }));
+ rafId = requestAnimationFrame(poll);
+ };
+
+ const onGamepadConnected = () => {
+ if (!enabled) return;
+ syncStyleIndex();
+ setConnected(true);
+ };
+
+ const onGamepadDisconnected = () => {
+ if (!(navigator.getGamepads?.() || []).some((pad) => pad?.connected)) {
+ endPushToTalk();
+ setConnected(false);
+ if (cameraActive) {
+ releaseContinuousRender('deck-controls');
+ cameraActive = false;
+ }
+ }
+ };
+
+ window.addEventListener('gamepadconnected', onGamepadConnected);
+ window.addEventListener('gamepaddisconnected', onGamepadDisconnected);
+
+ const toggleBtn = document.getElementById('deck-controls-toggle');
+ const switchEl = document.getElementById('deck-controls-switch');
+ const onToggleClick = () => setEnabled(!enabled);
+ const onSwitchChange = () => setEnabled(Boolean(switchEl?.checked));
+ toggleBtn?.addEventListener('click', onToggleClick);
+ switchEl?.addEventListener('change', onSwitchChange);
+ syncToggleUi();
+
+ rafId = requestAnimationFrame(poll);
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ window.removeEventListener('gamepadconnected', onGamepadConnected);
+ window.removeEventListener('gamepaddisconnected', onGamepadDisconnected);
+ toggleBtn?.removeEventListener('click', onToggleClick);
+ switchEl?.removeEventListener('change', onSwitchChange);
+ endPushToTalk();
+ releasePointerLook();
+ if (document.pointerLockElement === canvas) document.exitPointerLock?.();
+ releaseContinuousRender('deck-controls');
+ reticle.remove();
+ helpPanel.remove();
+ document.body.classList.remove('deck-controls-active');
+ document.body.classList.remove('deck-help-hidden');
+ };
+}
diff --git a/src/deckControls.test.mjs b/src/deckControls.test.mjs
new file mode 100644
index 0000000..23fb366
--- /dev/null
+++ b/src/deckControls.test.mjs
@@ -0,0 +1,52 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ DECK_CONTROLS_STORAGE_KEY,
+ DECK_LOOK_FULL_ALTITUDE_M,
+ DECK_LOOK_MIN_ALTITUDE_M,
+ DECK_LOOK_MIN_SCALE,
+ deckLookSensitivityScale,
+ readDeckControlsEnabled,
+ writeDeckControlsEnabled,
+} from './deckControls.js';
+
+test('deck look sensitivity is lowest near the ground and full at regional altitude', () => {
+ assert.equal(deckLookSensitivityScale(DECK_LOOK_MIN_ALTITUDE_M), DECK_LOOK_MIN_SCALE);
+ assert.equal(deckLookSensitivityScale(DECK_LOOK_FULL_ALTITUDE_M), 1);
+ assert.ok(deckLookSensitivityScale(2_000) < deckLookSensitivityScale(40_000));
+ assert.ok(deckLookSensitivityScale(40_000) < deckLookSensitivityScale(DECK_LOOK_FULL_ALTITUDE_M));
+});
+
+test('deck look sensitivity clamps below min and above full altitude', () => {
+ assert.equal(deckLookSensitivityScale(0), DECK_LOOK_MIN_SCALE);
+ assert.equal(deckLookSensitivityScale(-100), DECK_LOOK_MIN_SCALE);
+ assert.equal(deckLookSensitivityScale(DECK_LOOK_FULL_ALTITUDE_M + 50_000), 1);
+});
+
+test('deck controls slider preference prefers stored value over env default', () => {
+ const storage = new Map();
+ const fake = {
+ getItem: (key) => (storage.has(key) ? storage.get(key) : null),
+ setItem: (key, value) => { storage.set(key, String(value)); },
+ };
+ assert.equal(readDeckControlsEnabled(fake, '0'), false);
+ assert.equal(readDeckControlsEnabled(fake, '1'), true);
+ fake.setItem(DECK_CONTROLS_STORAGE_KEY, '1');
+ assert.equal(readDeckControlsEnabled(fake, '0'), true);
+ fake.setItem(DECK_CONTROLS_STORAGE_KEY, '0');
+ assert.equal(readDeckControlsEnabled(fake, '1'), false);
+});
+
+test('writeDeckControlsEnabled persists 1/0 and survives read-back', () => {
+ const storage = new Map();
+ const fake = {
+ getItem: (key) => (storage.has(key) ? storage.get(key) : null),
+ setItem: (key, value) => { storage.set(key, String(value)); },
+ };
+ writeDeckControlsEnabled(true, fake);
+ assert.equal(fake.getItem(DECK_CONTROLS_STORAGE_KEY), '1');
+ assert.equal(readDeckControlsEnabled(fake, '0'), true);
+ writeDeckControlsEnabled(false, fake);
+ assert.equal(fake.getItem(DECK_CONTROLS_STORAGE_KEY), '0');
+ assert.equal(readDeckControlsEnabled(fake, '1'), false);
+});
diff --git a/src/main.js b/src/main.js
index 84fbf12..2f42032 100644
--- a/src/main.js
+++ b/src/main.js
@@ -33,6 +33,7 @@ import {
import { installScopeMask } from './scopeMask.js';
import { initFirstRunExperience } from './firstRunExperience.js';
import { initKeySetup } from './keySetup.js';
+import { initDeckControls } from './deckControls.js';
import { loadPhotorealisticTileset } from './mapStartup.js';
initLogoGaze();
@@ -327,6 +328,7 @@ async function init() {
requestRender: governorRequestRender,
};
window.__godsEyeView.voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations });
+ initDeckControls(viewer, styleManager);
} catch (error) {
console.error("God's Eye View initialization failed:", error);
diff --git a/style.css b/style.css
index 3941027..0bede73 100644
--- a/style.css
+++ b/style.css
@@ -1056,6 +1056,57 @@ body.cockpit-mode #cockpit-cloud-effects.active {
display: flex;
}
+.pp-switch {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ margin-left: auto;
+ cursor: pointer;
+}
+
+.pp-switch input {
+ position: absolute;
+ opacity: 0;
+ width: 1px;
+ height: 1px;
+}
+
+.pp-switch-ui {
+ width: 32px;
+ height: 16px;
+ border-radius: 99px;
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ position: relative;
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+
+.pp-switch-ui::after {
+ content: '';
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #d8ece6;
+ transition: transform 0.15s ease;
+}
+
+.pp-switch input:checked + .pp-switch-ui {
+ background: rgba(31, 143, 109, 0.85);
+ border-color: rgba(125, 255, 200, 0.45);
+}
+
+.pp-switch input:checked + .pp-switch-ui::after {
+ transform: translateX(16px);
+}
+
+.pp-switch input:focus-visible + .pp-switch-ui {
+ outline: 1px solid rgba(125, 255, 200, 0.7);
+ outline-offset: 2px;
+}
+
.pp-toggle-group .pp-toggle-btn {
transition: all var(--transition-fast), border-radius var(--transition-fast);
}
@@ -9520,3 +9571,89 @@ body.scene-playback-mode #key-setup {
border-color: rgba(255, 170, 150, 0.65);
outline: none;
}
+/* Steam Deck / gamepad controls */
+#deck-reticle {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ width: 28px;
+ height: 28px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ z-index: 1200;
+ opacity: 0.75;
+}
+
+#deck-reticle .deck-reticle-h,
+#deck-reticle .deck-reticle-v {
+ position: absolute;
+ background: rgba(120, 255, 200, 0.85);
+ box-shadow: 0 0 8px rgba(80, 220, 180, 0.45);
+}
+
+#deck-reticle .deck-reticle-h {
+ left: 0;
+ right: 0;
+ top: 50%;
+ height: 1px;
+ transform: translateY(-50%);
+}
+
+#deck-reticle .deck-reticle-v {
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 1px;
+ transform: translateX(-50%);
+}
+
+#deck-controls-help {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ z-index: 1200;
+ min-width: 180px;
+ padding: 10px 12px;
+ border: 1px solid rgba(120, 255, 200, 0.35);
+ background: rgba(8, 14, 18, 0.82);
+ color: rgba(220, 245, 240, 0.92);
+ font: 11px/1.35 'JetBrains Mono', monospace;
+ letter-spacing: 0.04em;
+ backdrop-filter: blur(8px);
+ pointer-events: none;
+}
+
+#deck-controls-help .deck-controls-title {
+ margin-bottom: 6px;
+ color: rgba(120, 255, 200, 0.95);
+ font-weight: 600;
+}
+
+#deck-controls-help .deck-controls-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ opacity: 0.9;
+}
+
+#deck-controls-help .deck-controls-note {
+ display: block;
+ margin-top: 6px;
+ font-size: 10px;
+ line-height: 1.35;
+ opacity: 0.82;
+}
+
+body.deck-controls-active #cesiumContainer {
+ cursor: none;
+}
+
+@media (max-width: 900px) {
+ #deck-controls-help {
+ right: 8px;
+ bottom: 8px;
+ font-size: 10px;
+ min-width: 150px;
+ }
+}
+
diff --git a/vite.config.js b/vite.config.js
index 34dac52..87bbf74 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -7788,6 +7788,8 @@ export default defineConfig(({ mode }) => {
define: {
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(env.GOOGLE_MAPS_API_KEY),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(env.CESIUM_ION_TOKEN),
+ // Gamepad / Steam Deck controls default on; set GEV_DECK_CONTROLS=0 to default off.
+ 'import.meta.env.GEV_DECK_CONTROLS': JSON.stringify(env.GEV_DECK_CONTROLS === '0' ? '0' : '1'),
},
build: {
// The Cesium engine bundle is inherently large; raise the warning ceiling