/**
* This could use a lot of work, for example, due to the way both of:
* - how the component is currently composed
* - how it's currently hooked into the cesium viewer
* we needlessly force re-render it all even though there is no change to orbit
* or heading
*
* You'll also see a few weird numbers - this is due to the port from the scss
* styles, and will be leaving it as is for now
*/
//
import debounce from "lodash-es/debounce";
import { computed, runInAction, when } from "mobx";
import { Ref, PureComponent } from "react";
import { WithTranslation, withTranslation, TFunction } from "react-i18next";
import styled, { DefaultTheme, withTheme } from "styled-components";
import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2";
import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3";
import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid";
import CesiumEvent from "terriajs-cesium/Source/Core/Event";
import CesiumMath from "terriajs-cesium/Source/Core/Math";
import Matrix4 from "terriajs-cesium/Source/Core/Matrix4";
import Ray from "terriajs-cesium/Source/Core/Ray";
import Transforms from "terriajs-cesium/Source/Core/Transforms";
import getTimestamp from "terriajs-cesium/Source/Core/getTimestamp";
import CameraFlightPath from "terriajs-cesium/Source/Scene/CameraFlightPath";
import Scene from "terriajs-cesium/Source/Scene/Scene";
import compassRotationMarker from "../../../../../../wwwroot/images/compass-rotation-marker.svg";
import isDefined from "../../../../../Core/isDefined";
import Terria from "../../../../../Models/Terria";
import ViewState from "../../../../../ReactViewModels/ViewState";
import Box from "../../../../../Styled/Box";
import Icon, { StyledIcon } from "../../../../../Styled/Icon";
import { withTerriaRef } from "../../../../HOCs/withTerriaRef";
import FadeIn from "../../../../Transitions/FadeIn/FadeIn";
import { GyroscopeGuidance } from "./GyroscopeGuidance";
export const COMPASS_LOCAL_PROPERTY_KEY = "CompassHelpPrompted";
// Map Compass
//
// Markup:
//
// ( if hovered/active/focused)
// (base, turns into white circle when active)
// (clone to be used for animation)
//
//
//
interface StyledCompassProps {
active: boolean;
theme: DefaultTheme;
}
const StyledCompass = styled.div`
display: none;
position: relative;
width: ${(props) => props.theme.compassWidth}px;
height: ${(props) => props.theme.compassWidth}px;
@media (min-width: ${(props) => props.theme.sm}px) {
display: block;
}
`;
/**
* Take a compass width and scale it up 10px, instead of hardcoding values like:
* // const compassScaleRatio = 66 / 56;
*/
const getCompassScaleRatio = (compassWidth: string) =>
(Number(compassWidth) + 10) / Number(compassWidth);
/**
* You think 0.9999 is a joke but I kid you not, it's the root of all evil in
* bandaging these related issues:
* https://github.com/TerriaJS/terriajs/issues/4261
* https://github.com/TerriaJS/terriajs/pull/4262
* https://github.com/TerriaJS/terriajs/pull/4213
*
* It seems the rendering in Chrome means that in certain conditions
* - chrome (not even another webkit browser)
* - "default browser zoom" (doesn't happen when you are even at 110%, but will
* when shrunk down enough)
* - the way our compass is composed
*
* The action of triggering the 'active' state (scaled up to
* `getCompassScaleRatio()`) & back down means that the "InnerRing" will look
* off-center by 0.5-1px until you switch windows/tabs away and back, then
* chrome will decide to render it in the correct position.
*
* I haven't dug further to the root cause as doing it like this means wew now
* have a beautiful animating compass.
*
* So please leave scale(0.9999) alone unless you can fix the rendering issue in
* chrome, or if you want to develop a burning hatred for the compass 🙏🔥
*
**/
const StyledCompassOuterRing = styled.div`
${(props) => props.theme.centerWithoutFlex()}
// override the transform provided in centerWithoutFlex()
transform: translate(-50%,-50%) scale(0.9999);
z-index: ${(props) => (props.active ? "2" : "1")};
width: 100%;
${(props) =>
props.active &&
`transform: translate(-50%,-50%) scale(${getCompassScaleRatio(
props.theme.compassWidth
)});`};
transition: transform 0.3s;
`;
const StyledCompassInnerRing = styled.div`
${(props) => props.theme.verticalAlign()}
width: ${(props) =>
Number(props.theme.compassWidth) - Number(props.theme.ringWidth) - 10}px;
height: ${(props) =>
Number(props.theme.compassWidth) - Number(props.theme.ringWidth) - 10}px;
margin: 0 auto;
padding: 4px;
box-sizing: border-box;
`;
const StyledCompassRotationMarker = styled.div`
${(props) => props.theme.centerWithoutFlex()}
z-index: 3;
cursor: pointer;
width: ${(props) =>
Number(props.theme.compassWidth) + Number(props.theme.ringWidth) - 4}px;
height: ${(props) =>
Number(props.theme.compassWidth) + Number(props.theme.ringWidth) - 4}px;
border-radius: 50%;
background-repeat: no-repeat;
background-size: contain;
`;
type PropTypes = WithTranslation & {
terria: Terria;
viewState: ViewState;
refFromHOC?: Ref;
theme: DefaultTheme;
t: TFunction;
};
type IStateTypes = {
orbitCursorAngle: number;
heading: number;
orbitCursorOpacity: number;
active: boolean;
activeForTransition: boolean;
};
// the compass on map
class Compass extends PureComponent {
_unsubscribeFromPostRender: any;
_unsubscribeFromAnimationFrame: any;
private _unsubscribeFromViewerChange?: CesiumEvent.RemoveCallback;
orbitMouseMoveFunction?: (this: Document, ev: MouseEvent) => any;
orbitMouseUpFunction?: (this: Document, ev: MouseEvent) => any;
rotateMouseMoveFunction?: (this: Document, ev: MouseEvent) => any;
rotateMouseUpFunction?: (this: Document, ev: MouseEvent) => any;
isRotating: boolean = false;
rotateInitialCursorAngle: number = 0;
rotateFrame?: Matrix4;
rotateIsLook: boolean = false;
rotateInitialCameraAngle: number = 0;
rotateInitialCameraDistance: number = 0;
orbitFrame?: Matrix4;
orbitIsLook: boolean = false;
orbitLastTimestamp: number = 0;
isOrbiting: boolean = false;
orbitAnimationFrameFunction?: any;
showCompass?: boolean;
/**
* @param {Props} props
*/
constructor(props: PropTypes) {
super(props);
this.state = {
orbitCursorAngle: 0,
heading: 0.0,
orbitCursorOpacity: 0,
active: false,
activeForTransition: false
};
when(
() => isDefined(this.cesiumViewer),
() => this.cesiumLoaded()
);
}
@computed
get cesiumViewer() {
return this.props.terria.cesium;
}
cesiumLoaded() {
this._unsubscribeFromViewerChange =
this.props.terria.mainViewer.afterViewerChanged.addEventListener(() =>
viewerChange(this)
);
viewerChange(this);
}
componentWillUnmount() {
if (this.orbitMouseMoveFunction) {
document.removeEventListener(
"mousemove",
this.orbitMouseMoveFunction,
false
);
}
if (this.orbitMouseUpFunction) {
document.removeEventListener("mouseup", this.orbitMouseUpFunction, false);
}
if (this._unsubscribeFromAnimationFrame) {
this._unsubscribeFromAnimationFrame();
}
if (this._unsubscribeFromPostRender) {
this._unsubscribeFromPostRender();
}
if (this._unsubscribeFromViewerChange) {
this._unsubscribeFromViewerChange();
}
}
handleMouseDown(e: any) {
if (e.stopPropagation) e.stopPropagation();
if (e.preventDefault) e.preventDefault();
const compassElement = e.currentTarget;
const compassRectangle = e.currentTarget.getBoundingClientRect();
const maxDistance = compassRectangle.width / 2.0;
const center = new Cartesian2(
(compassRectangle.right - compassRectangle.left) / 2.0,
(compassRectangle.bottom - compassRectangle.top) / 2.0
);
const clickLocation = new Cartesian2(
e.clientX - compassRectangle.left,
e.clientY - compassRectangle.top
);
const vector = Cartesian2.subtract(clickLocation, center, vectorScratch);
const distanceFromCenter = Cartesian2.magnitude(vector);
const distanceFraction = distanceFromCenter / maxDistance;
const nominalTotalRadius = 145;
const nominalGyroRadius = 50;
if (distanceFraction < nominalGyroRadius / nominalTotalRadius) {
orbit(this, compassElement, vector);
} else if (distanceFraction < 1.0) {
rotate(this, compassElement, vector);
} else {
return true;
}
}
handleDoubleClick(_e: any) {
const scene = this.props.terria.cesium!.scene;
const camera = scene.camera;
const windowPosition = windowPositionScratch;
windowPosition.x = scene.canvas.clientWidth / 2;
windowPosition.y = scene.canvas.clientHeight / 2;
const ray = camera.getPickRay(windowPosition, pickRayScratch);
const center = isDefined(ray)
? scene.globe.pick(ray, scene, centerScratch)
: undefined;
if (!isDefined(center)) {
// Globe is barely visible, so reset to home view.
this.props.terria.currentViewer.zoomTo(
this.props.terria.mainViewer.homeCamera,
1.5
);
return;
}
const rotateFrame = Transforms.eastNorthUpToFixedFrame(
center,
Ellipsoid.WGS84
);
const lookVector = Cartesian3.subtract(
center,
camera.position,
new Cartesian3()
);
const flight = CameraFlightPath.createTween(scene, {
destination: Matrix4.multiplyByPoint(
rotateFrame,
new Cartesian3(0.0, 0.0, Cartesian3.magnitude(lookVector)),
new Cartesian3()
),
direction: Matrix4.multiplyByPointAsVector(
rotateFrame,
new Cartesian3(0.0, 0.0, -1.0),
new Cartesian3()
),
up: Matrix4.multiplyByPointAsVector(
rotateFrame,
new Cartesian3(0.0, 1.0, 0.0),
new Cartesian3()
),
duration: 1.5
});
scene.tweens.add(flight);
}
resetRotater() {
this.setState({
orbitCursorOpacity: 0,
orbitCursorAngle: 0
});
}
render() {
const rotationMarkerStyle = {
transform: "rotate(-" + this.state.orbitCursorAngle + "rad)",
WebkitTransform: "rotate(-" + this.state.orbitCursorAngle + "rad)",
opacity: this.state.orbitCursorOpacity
};
const outerCircleStyle = {
transform: "rotate(-" + this.state.heading + "rad)",
WebkitTransform: "rotate(-" + this.state.heading + "rad)",
opacity: ""
};
const { t } = this.props;
const active = this.state.active;
const description = t("compass.description");
const showGuidance = !this.props.viewState.terria.getLocalProperty(
COMPASS_LOCAL_PROPERTY_KEY
);
return (
{/* Bottom "turns into white circle when active" layer */}
{/* "Top" animated layer */}
{/* "Center circle icon" */}
{/* Rotation marker when dragging */}
this.setState({ active: true })}
onMouseOut={() => {
if (showGuidance) {
this.setState({ active: true });
} else {
this.setState({ active: false });
}
}}
// do we give focus to this? given it's purely a mouse tool
// focus it anyway..
tabIndex={0}
onFocus={() => this.setState({ active: true })}
// Gotta keep menu open if blurred, and close it with the close button
// instead. otherwise it'll never focus on the help buttons
// onBlur={() => this.setState({ active: false })}
>
{/* Gyroscope guidance menu */}
{showGuidance && (
p.theme.verticalAlign("absolute")}
direction: rtl;
right: 55px;
`}
>
{
// this.props.viewState.showHelpPanel();
// this.props.viewState.selectHelpMenuItem("navigation");
// }}
onClose={() => this.setState({ active: false })}
/>
)}
);
}
}
const vectorScratch = new Cartesian2();
const oldTransformScratch = new Matrix4();
const newTransformScratch = new Matrix4();
const centerScratch = new Cartesian3();
const windowPositionScratch = new Cartesian2();
const pickRayScratch = new Ray();
function rotate(
viewModel: Compass,
compassElement: Element,
cursorVector: Cartesian2
) {
// Remove existing event handlers, if any.
if (viewModel.rotateMouseMoveFunction) {
document.removeEventListener(
"mousemove",
viewModel.rotateMouseMoveFunction,
false
);
}
if (viewModel.rotateMouseUpFunction) {
document.removeEventListener(
"mouseup",
viewModel.rotateMouseUpFunction,
false
);
}
viewModel.rotateMouseMoveFunction = undefined;
viewModel.rotateMouseUpFunction = undefined;
viewModel.isRotating = true;
viewModel.rotateInitialCursorAngle = Math.atan2(
-cursorVector.y,
cursorVector.x
);
const scene = viewModel.props.terria.cesium!.scene;
let camera = scene.camera;
const windowPosition = windowPositionScratch;
windowPosition.x = scene.canvas.clientWidth / 2;
windowPosition.y = scene.canvas.clientHeight / 2;
const ray = camera.getPickRay(windowPosition, pickRayScratch);
const viewCenter = isDefined(ray)
? scene.globe.pick(ray, scene, centerScratch)
: undefined;
if (!isDefined(viewCenter)) {
viewModel.rotateFrame = Transforms.eastNorthUpToFixedFrame(
camera.positionWC,
Ellipsoid.WGS84,
newTransformScratch
);
viewModel.rotateIsLook = true;
} else {
viewModel.rotateFrame = Transforms.eastNorthUpToFixedFrame(
viewCenter,
Ellipsoid.WGS84,
newTransformScratch
);
viewModel.rotateIsLook = false;
}
let oldTransform = Matrix4.clone(camera.transform, oldTransformScratch);
camera.lookAtTransform(viewModel.rotateFrame);
viewModel.rotateInitialCameraAngle = Math.atan2(
camera.position.y,
camera.position.x
);
viewModel.rotateInitialCameraDistance = Cartesian3.magnitude(
new Cartesian3(camera.position.x, camera.position.y, 0.0)
);
camera.lookAtTransform(oldTransform);
viewModel.rotateMouseMoveFunction = function (e) {
const compassRectangle = compassElement.getBoundingClientRect();
const center = new Cartesian2(
(compassRectangle.right - compassRectangle.left) / 2.0,
(compassRectangle.bottom - compassRectangle.top) / 2.0
);
const clickLocation = new Cartesian2(
e.clientX - compassRectangle.left,
e.clientY - compassRectangle.top
);
const vector = Cartesian2.subtract(clickLocation, center, vectorScratch);
const angle = Math.atan2(-vector.y, vector.x);
const angleDifference = angle - viewModel.rotateInitialCursorAngle;
const newCameraAngle = CesiumMath.zeroToTwoPi(
viewModel.rotateInitialCameraAngle - angleDifference
);
camera = viewModel.props.terria.cesium!.scene.camera;
oldTransform = Matrix4.clone(camera.transform, oldTransformScratch);
camera.lookAtTransform(viewModel.rotateFrame!);
const currentCameraAngle = Math.atan2(camera.position.y, camera.position.x);
camera.rotateRight(newCameraAngle - currentCameraAngle);
camera.lookAtTransform(oldTransform);
// viewModel.props.terria.cesium.notifyRepaintRequired();
};
viewModel.rotateMouseUpFunction = function (_e) {
viewModel.isRotating = false;
if (viewModel.rotateMouseMoveFunction) {
document.removeEventListener(
"mousemove",
viewModel.rotateMouseMoveFunction,
false
);
}
if (viewModel.rotateMouseUpFunction) {
document.removeEventListener(
"mouseup",
viewModel.rotateMouseUpFunction,
false
);
}
viewModel.rotateMouseMoveFunction = undefined;
viewModel.rotateMouseUpFunction = undefined;
};
document.addEventListener(
"mousemove",
viewModel.rotateMouseMoveFunction,
false
);
document.addEventListener("mouseup", viewModel.rotateMouseUpFunction, false);
}
function orbit(
viewModel: Compass,
compassElement: Element,
cursorVector: Cartesian2
) {
// Remove existing event handlers, if any.
if (viewModel.orbitMouseMoveFunction) {
document.removeEventListener(
"mousemove",
viewModel.orbitMouseMoveFunction,
false
);
}
if (viewModel.orbitMouseUpFunction) {
document.removeEventListener(
"mouseup",
viewModel.orbitMouseUpFunction,
false
);
}
if (viewModel._unsubscribeFromAnimationFrame) {
viewModel._unsubscribeFromAnimationFrame();
}
viewModel._unsubscribeFromAnimationFrame = undefined;
viewModel.orbitMouseMoveFunction = undefined;
viewModel.orbitMouseUpFunction = undefined;
viewModel.orbitAnimationFrameFunction = undefined;
viewModel.isOrbiting = true;
viewModel.orbitLastTimestamp = getTimestamp();
const scene = viewModel.props.terria.cesium!.scene;
const camera = scene.camera;
const windowPosition = windowPositionScratch;
windowPosition.x = scene.canvas.clientWidth / 2;
windowPosition.y = scene.canvas.clientHeight / 2;
const ray = camera.getPickRay(windowPosition, pickRayScratch);
const center = isDefined(ray)
? scene.globe.pick(ray, scene, centerScratch)
: undefined;
if (!isDefined(center)) {
viewModel.orbitFrame = Transforms.eastNorthUpToFixedFrame(
camera.positionWC,
Ellipsoid.WGS84,
newTransformScratch
);
viewModel.orbitIsLook = true;
} else {
viewModel.orbitFrame = Transforms.eastNorthUpToFixedFrame(
center,
Ellipsoid.WGS84,
newTransformScratch
);
viewModel.orbitIsLook = false;
}
viewModel.orbitAnimationFrameFunction = function (_e: any) {
const timestamp = getTimestamp();
const deltaT = timestamp - viewModel.orbitLastTimestamp;
const rate = ((viewModel.state.orbitCursorOpacity - 0.5) * 2.5) / 1000;
const distance = deltaT * rate;
const angle = viewModel.state.orbitCursorAngle + CesiumMath.PI_OVER_TWO;
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
const scene = viewModel.props.terria.cesium!.scene;
const camera = scene.camera;
const oldTransform = Matrix4.clone(camera.transform, oldTransformScratch);
camera.lookAtTransform(viewModel.orbitFrame!);
if (viewModel.orbitIsLook) {
camera.look(Cartesian3.UNIT_Z, -x);
camera.look(camera.right, -y);
} else {
camera.rotateLeft(x);
camera.rotateUp(y);
}
camera.lookAtTransform(oldTransform);
// viewModel.props.terria.cesium.notifyRepaintRequired();
viewModel.orbitLastTimestamp = timestamp;
};
function updateAngleAndOpacity(vector: Cartesian2, compassWidth: number) {
const angle = Math.atan2(-vector.y, vector.x);
viewModel.setState({
orbitCursorAngle: CesiumMath.zeroToTwoPi(angle - CesiumMath.PI_OVER_TWO)
});
const distance = Cartesian2.magnitude(vector);
const maxDistance = compassWidth / 2.0;
const distanceFraction = Math.min(distance / maxDistance, 1.0);
const easedOpacity = 0.5 * distanceFraction * distanceFraction + 0.5;
viewModel.setState({
orbitCursorOpacity: easedOpacity
});
// viewModel.props.terria.cesium.notifyRepaintRequired();
}
viewModel.orbitMouseMoveFunction = function (e) {
const compassRectangle = compassElement.getBoundingClientRect();
const center = new Cartesian2(
(compassRectangle.right - compassRectangle.left) / 2.0,
(compassRectangle.bottom - compassRectangle.top) / 2.0
);
const clickLocation = new Cartesian2(
e.clientX - compassRectangle.left,
e.clientY - compassRectangle.top
);
const vector = Cartesian2.subtract(clickLocation, center, vectorScratch);
updateAngleAndOpacity(vector, compassRectangle.width);
};
viewModel.orbitMouseUpFunction = function (_e: any) {
// TODO: if mouse didn't move, reset view to looking down, north is up?
viewModel.isOrbiting = false;
if (viewModel.orbitMouseMoveFunction) {
document.removeEventListener(
"mousemove",
viewModel.orbitMouseMoveFunction,
false
);
}
if (viewModel.orbitMouseUpFunction) {
document.removeEventListener(
"mouseup",
viewModel.orbitMouseUpFunction,
false
);
}
if (viewModel._unsubscribeFromAnimationFrame) {
viewModel._unsubscribeFromAnimationFrame();
}
viewModel._unsubscribeFromAnimationFrame = undefined;
viewModel.orbitMouseMoveFunction = undefined;
viewModel.orbitMouseUpFunction = undefined;
viewModel.orbitAnimationFrameFunction = undefined;
};
document.addEventListener(
"mousemove",
viewModel.orbitMouseMoveFunction,
false
);
document.addEventListener("mouseup", viewModel.orbitMouseUpFunction, false);
subscribeToAnimationFrame(viewModel);
updateAngleAndOpacity(
cursorVector,
compassElement.getBoundingClientRect().width
);
}
function subscribeToAnimationFrame(viewModel: Compass) {
viewModel._unsubscribeFromAnimationFrame = (
(id) => () =>
cancelAnimationFrame(id)
)(
requestAnimationFrame(() => {
if (isDefined(viewModel.orbitAnimationFrameFunction)) {
viewModel.orbitAnimationFrameFunction();
}
subscribeToAnimationFrame(viewModel);
})
);
}
function viewerChange(viewModel: Compass) {
runInAction(() => {
if (isDefined(viewModel.props.terria.cesium)) {
if (viewModel._unsubscribeFromPostRender) {
viewModel._unsubscribeFromPostRender();
viewModel._unsubscribeFromPostRender = undefined;
}
viewModel._unsubscribeFromPostRender =
viewModel.props.terria.cesium.scene.postRender.addEventListener(
debounce(
function (scene: Scene) {
if ((scene as any).view) {
viewModel.setState({
heading: scene.camera.heading
});
}
},
200,
{
maxWait: 200,
leading: true,
trailing: true
}
)
);
} else {
if (viewModel._unsubscribeFromPostRender) {
viewModel._unsubscribeFromPostRender();
viewModel._unsubscribeFromPostRender = undefined;
}
viewModel.showCompass = false;
}
});
}
export const COMPASS_NAME = "MapNavigationCompassOuterRing";
export const COMPASS_TOOL_ID = "compass";
export default withTranslation()(
withTheme(withTerriaRef(Compass, COMPASS_NAME))
);