diff --git a/lib/ReactViews/Map/MapNavigation/registerMapNavigations.tsx b/lib/ReactViews/Map/MapNavigation/registerMapNavigations.tsx index 905dcf60682..a9b074d294e 100644 --- a/lib/ReactViews/Map/MapNavigation/registerMapNavigations.tsx +++ b/lib/ReactViews/Map/MapNavigation/registerMapNavigations.tsx @@ -30,6 +30,9 @@ import { ZOOM_CONTROL_ID } from "./Items"; import { TogglePickInfoController } from "./Items/TogglePickInfoTool"; +import KeyboardMode, { + KEYBOARD_MODE_ID +} from "../../Tools/KeyboardMode/KeyboardMode"; export const CLOSE_TOOL_ID = "close-tool"; @@ -248,6 +251,22 @@ export const registerMapNavigations = (viewState: ViewState) => { noExpand: true }); + const keyboardModeToolController = new ToolButtonController({ + toolName: KEYBOARD_MODE_ID, + viewState: viewState, + getToolComponent: () => KeyboardMode as any, + icon: GLYPHS.keyboard + }); + mapNavigationModel.addItem({ + id: KEYBOARD_MODE_ID, + name: "translate#keyboardControls.toolButtonTitle", + title: "translate#keyboardControls.toolButtonTitle", + location: "TOP", + screenSize: "medium", + controller: keyboardModeToolController, + order: 9 + }); + const feedbackController = new FeedbackButtonController(viewState); mapNavigationModel.addItem({ id: FEEDBACK_TOOL_ID, diff --git a/lib/ReactViews/Tools/KeyboardMode/KeyboardMode.tsx b/lib/ReactViews/Tools/KeyboardMode/KeyboardMode.tsx new file mode 100644 index 00000000000..5cdd3cf2bd0 --- /dev/null +++ b/lib/ReactViews/Tools/KeyboardMode/KeyboardMode.tsx @@ -0,0 +1,38 @@ +import { observer } from "mobx-react"; +import React from "react"; +import styled from "styled-components"; +import Cesium from "../../../Models/Cesium"; +import ViewState from "../../../ReactViewModels/ViewState"; +import PositionRightOfWorkbench from "../../Workbench/PositionRightOfWorkbench"; +import MovementControls from "./MovementControls"; + +type KeyboardModeProps = { + viewState: ViewState; +}; +export const KEYBOARD_MODE_ID = "keyboard-mode"; + +const KeyboardMode: React.FC = observer((props) => { + const { viewState } = props; + + const cesium = viewState.terria.currentViewer; + + if (!(cesium instanceof Cesium)) { + viewState.closeTool(); + return null; + } + + return ( + + + + ); +}); + +const ControlsContainer = styled(PositionRightOfWorkbench)` + width: 140px; + top: unset; + left: 0; + bottom: 300px; +`; + +export default KeyboardMode; diff --git a/lib/ReactViews/Tools/KeyboardMode/MovementControls.tsx b/lib/ReactViews/Tools/KeyboardMode/MovementControls.tsx new file mode 100644 index 00000000000..ab22f89f11d --- /dev/null +++ b/lib/ReactViews/Tools/KeyboardMode/MovementControls.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Cesium from "../../../Models/Cesium"; +import Box from "../../../Styled/Box"; +import Button from "../../../Styled/Button"; +import Text from "../../../Styled/Text"; +import Icon, { StyledIcon } from "../../../Styled/Icon"; +import MovementsController from "./MovementsController"; + +const wasdControlsImage = require("../../../../wwwroot/images/keyboard_controls.svg"); + +type MovementControlsProps = { + cesium: Cesium; +}; + +const MovementControls: React.FC = (props) => { + const [isMaximized, setIsMaximized] = useState(true); + const [t] = useTranslation(); + + const toggleMaximized = () => setIsMaximized(!isMaximized); + + useEffect(() => { + const movementsController = new MovementsController(props.cesium); + const detach = movementsController.activate(); + return detach; + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [props.cesium]); + + return ( + + + <Text medium>{t("keyboardControls.header")}</Text> + <MinimizeMaximizeButton + onClick={toggleMaximized} + maximized={isMaximized} + /> + + {isMaximized && ( + + Direction controls + + )} + + ); +}; + +const backgroundColor = "#ffffff"; + +const Container = styled.div` + background-color: ${backgroundColor}; + box-shadow: 0 4px 8px 4px rgb(0 0 0 / 5%); + border-radius: 3px; +`; + +const Title = styled(Box).attrs({ + medium: true +})` + justify-content: space-between; + align-items: center; + padding: 0 0.5em; + border-bottom: 1px solid #c0c0c0; +`; + +const MinimizeMaximizeButton = styled(Button).attrs(({ maximized }) => ({ + renderIcon: () => ( + + ) +}))<{ maximized: boolean }>` + padding: 0; + margin: 0; + border: 0; + background-color: ${backgroundColor}; +`; + +const ButtonIcon = styled(StyledIcon)` + height: 20px; +`; + +const Body = styled(Box).attrs({ column: true, centered: true })` + align-items: center; + margin-top: 1em; + & img { + padding-bottom: 1em; + } +`; + +export default MovementControls; diff --git a/lib/ReactViews/Tools/KeyboardMode/MovementsController.ts b/lib/ReactViews/Tools/KeyboardMode/MovementsController.ts new file mode 100644 index 00000000000..f73745ca123 --- /dev/null +++ b/lib/ReactViews/Tools/KeyboardMode/MovementsController.ts @@ -0,0 +1,213 @@ +import { action, makeObservable } from "mobx"; +import ScreenSpaceEventHandler from "terriajs-cesium/Source/Core/ScreenSpaceEventHandler"; +import Cesium from "../../../Models/Cesium"; + +type Movement = + | "forward" + | "backward" + | "left" + | "right" + | "up" + | "down" + | "lookUp" + | "lookDown" + | "lookLeft" + | "lookRight"; + +const KeyMap: Record = { + KeyW: "forward", + KeyA: "left", + KeyS: "backward", + KeyD: "right", + KeyZ: "up", + KeyX: "down", + KeyQ: "lookLeft", + KeyE: "lookRight", + KeyR: "lookUp", + KeyF: "lookDown" +}; + +export default class MovementsController { + // Current active movements + activeMovements: Set = new Set(); + + constructor(readonly cesium: Cesium) { + makeObservable(this); + } + + get scene() { + return this.cesium.scene; + } + + get camera() { + return this.scene.camera; + } + + /** + * moveAmount decides the motion speed. + */ + get moveAmount() { + const cameraHeight = this.camera.positionCartographic.height; + const moveRate = cameraHeight / 100.0; + return moveRate; + } + + /** + * Perform a move step + */ + move(movement: Movement) { + switch (movement) { + case "forward": + this.camera.moveForward(this.moveAmount); + break; + case "backward": + this.camera.moveBackward(this.moveAmount); + break; + case "left": + this.camera.moveLeft(this.moveAmount); + break; + case "right": + this.camera.moveRight(this.moveAmount); + break; + case "up": + this.camera.moveUp(this.moveAmount); + break; + case "down": + this.camera.moveDown(this.moveAmount); + break; + case "lookUp": + this.camera.lookUp(); + break; + case "lookDown": + this.camera.lookDown(); + break; + case "lookLeft": + this.camera.lookLeft(); + break; + case "lookRight": + this.camera.lookRight(); + break; + } + } + + animate() { + if (this.activeMovements.size > 0) { + [...this.activeMovements].forEach((movement) => this.move(movement)); + } + } + + /** + * Map keyboard events to movements + */ + setupKeyMap(): () => void { + const onKeyDown = (ev: KeyboardEvent) => { + if ( + // do not match if any modifiers are pressed so that we do not hijack window shortcuts. + ev.ctrlKey === false && + ev.altKey === false && + KeyMap[ev.code] !== undefined + ) + this.activeMovements.add(KeyMap[ev.code]); + }; + + const onKeyUp = (ev: KeyboardEvent) => { + if (KeyMap[ev.code] !== undefined) + this.activeMovements.delete(KeyMap[ev.code]); + }; + + document.addEventListener("keydown", excludeInputEvents(onKeyDown), true); + document.addEventListener("keyup", excludeInputEvents(onKeyUp), true); + + const keyMapDestroyer = () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); + }; + + return keyMapDestroyer; + } + + /** + * Map mouse events to movements + */ + setupMouseMap(): () => void { + const eventHandler = new ScreenSpaceEventHandler(this.scene.canvas); + + const mouseMapDestroyer = () => eventHandler.destroy(); + return mouseMapDestroyer; + } + + /** + * Animate on each clock tick + */ + startAnimating() { + const stopAnimating = + this.cesium.cesiumWidget.clock.onTick.addEventListener( + this.animate.bind(this) + ); + return stopAnimating; + } + + /** + * Activates MovementsController + * + * 1. Disables default map interactions. + * 2. Sets up keyboard, mouse & animation event handlers. + * + * @returns A function to de-activate the movements controller + */ + @action + activate(): () => void { + // Disable other map controls + this.scene.screenSpaceCameraController.enableTranslate = false; + this.scene.screenSpaceCameraController.enableRotate = false; + this.scene.screenSpaceCameraController.enableLook = false; + this.scene.screenSpaceCameraController.enableTilt = false; + this.scene.screenSpaceCameraController.enableZoom = false; + this.cesium.isFeaturePickingPaused = true; + + const destroyKeyMap = this.setupKeyMap(); + const destroyMouseMap = this.setupMouseMap(); + const stopAnimating = this.startAnimating(); + + const deactivate = action(() => { + destroyKeyMap(); + destroyMouseMap(); + stopAnimating(); + + const screenSpaceCameraController = + this.scene.screenSpaceCameraController; + // screenSpaceCameraController will be undefined if the cesium map is already destroyed + if (screenSpaceCameraController !== undefined) { + screenSpaceCameraController.enableTranslate = true; + screenSpaceCameraController.enableRotate = true; + screenSpaceCameraController.enableLook = true; + screenSpaceCameraController.enableTilt = true; + screenSpaceCameraController.enableZoom = true; + } + this.cesium.isFeaturePickingPaused = false; + }); + + return deactivate; + } +} + +// A regex matching input tag names +const inputNodeRe = /input|textarea|select/i; + +function excludeInputEvents( + handler: (ev: KeyboardEvent) => void +): (ev: KeyboardEvent) => void { + return (ev) => { + const target = ev.target; + if (target !== null) { + const nodeName = (target as any).nodeName; + const isContentEditable = (target as any).getAttribute?.( + "contenteditable" + ); + if (isContentEditable || inputNodeRe.test(nodeName)) { + return; + } + } + handler(ev); + }; +} diff --git a/lib/Styled/Icon.tsx b/lib/Styled/Icon.tsx index 8aaace722d0..d1eb2947ae1 100644 --- a/lib/Styled/Icon.tsx +++ b/lib/Styled/Icon.tsx @@ -42,6 +42,7 @@ export const GLYPHS = { help: require("../../wwwroot/images/icons/help.svg"), helpThick: require("../../wwwroot/images/icons/help-thick.svg"), increase: require("../../wwwroot/images/icons/increase.svg"), + keyboard: require("../../wwwroot/images/icons/keyboard.svg"), left: require("../../wwwroot/images/icons/left.svg"), lineChart: require("../../wwwroot/images/icons/line-chart.svg"), link: require("../../wwwroot/images/icons/link.svg"), diff --git a/wwwroot/images/icons/keyboard.svg b/wwwroot/images/icons/keyboard.svg new file mode 100644 index 00000000000..787b19cbc7b --- /dev/null +++ b/wwwroot/images/icons/keyboard.svg @@ -0,0 +1,60 @@ + + + + + + + + \ No newline at end of file diff --git a/wwwroot/images/keyboard_controls.svg b/wwwroot/images/keyboard_controls.svg new file mode 100644 index 00000000000..3b7842073d9 --- /dev/null +++ b/wwwroot/images/keyboard_controls.svg @@ -0,0 +1,262 @@ + + + + + graphic/keyboard_controls + + + + graphic/keyboard_controls + + + + + + + W + + + + D + + + + S + + + + A + + + + X + + + + Z + + + + + E + + + + Q + + + + R + + + + F + + + diff --git a/wwwroot/languages/en/translation.json b/wwwroot/languages/en/translation.json index b2275c6e2a0..cd9d644e0b9 100644 --- a/wwwroot/languages/en/translation.json +++ b/wwwroot/languages/en/translation.json @@ -2198,6 +2198,10 @@ "title": "Controls" } }, + "keyboardControls": { + "toolButtonTitle": "Keyboard navigation", + "header": "Keyboard map controls" + }, "notification": { "title": "Message" }, diff --git a/wwwroot/languages/it/translation.json b/wwwroot/languages/it/translation.json index 61b709330f8..c0c3a3cd4ad 100644 --- a/wwwroot/languages/it/translation.json +++ b/wwwroot/languages/it/translation.json @@ -1558,6 +1558,10 @@ "toolButtonTitle": "Modalità pedonale", "dropPedestrianTooltipMessage": "Clic sinistro per selezionare la posizione della goccia
Clic destro / Esc per annullare" }, + "keyboardControls": { + "toolButtonTitle": "Navigazione da tastiera", + "header": "Controlli mappa da tastiera" + }, "itemSearchTool": { "resetBtnText": "Cancella input", "searchBtnText": "Cerca",