Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map navigation with keyboard #54

Merged
merged 3 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/ReactViews/Map/MapNavigation/registerMapNavigations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions lib/ReactViews/Tools/KeyboardMode/KeyboardMode.tsx
Original file line number Diff line number Diff line change
@@ -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<KeyboardModeProps> = observer((props) => {
const { viewState } = props;

const cesium = viewState.terria.currentViewer;

if (!(cesium instanceof Cesium)) {
viewState.closeTool();
return null;
}

return (
<ControlsContainer viewState={viewState}>
<MovementControls cesium={cesium} />
</ControlsContainer>
);
});

const ControlsContainer = styled(PositionRightOfWorkbench)`
width: 140px;
top: unset;
left: 0;
bottom: 300px;
`;

export default KeyboardMode;
90 changes: 90 additions & 0 deletions lib/ReactViews/Tools/KeyboardMode/MovementControls.tsx
Original file line number Diff line number Diff line change
@@ -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<MovementControlsProps> = (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 (
<Container>
<Title>
<Text medium>{t("keyboardControls.header")}</Text>
<MinimizeMaximizeButton
onClick={toggleMaximized}
maximized={isMaximized}
/>
</Title>
{isMaximized && (
<Body>
<img alt="Direction controls" src={wasdControlsImage} />
</Body>
)}
</Container>
);
};

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: () => (
<ButtonIcon
glyph={maximized ? Icon.GLYPHS.minimize : Icon.GLYPHS.maximize}
/>
)
}))<{ 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;
213 changes: 213 additions & 0 deletions lib/ReactViews/Tools/KeyboardMode/MovementsController.ts
Original file line number Diff line number Diff line change
@@ -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<KeyboardEvent["code"], Movement> = {
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<Movement> = 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);
};
}
1 change: 1 addition & 0 deletions lib/Styled/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading