@egjs/view360
Version:
360 integrated viewing solution from inside-out view to outside-in view. It provides user-friendly service by rotating 360 degrees through various user interaction such as motion sensor and touch.
672 lines (573 loc) • 18.3 kB
JavaScript
import Component from "@egjs/component";
import Axes, {PinchInput, MoveKeyInput, WheelInput} from "@egjs/axes";
import {getComputedStyle, SUPPORT_TOUCH, SUPPORT_DEVICEMOTION} from "../utils/browserFeature";
import TiltMotionInput from "./input/TiltMotionInput";
import RotationPanInput from "./input/RotationPanInput";
import DeviceQuaternion from "./DeviceQuaternion";
import {
vec2,
} from "../utils/math-util";
import {
GYRO_MODE,
TOUCH_DIRECTION_YAW,
TOUCH_DIRECTION_PITCH,
TOUCH_DIRECTION_ALL,
MC_DECELERATION,
MC_MAXIMUM_DURATION,
MC_BIND_SCALE,
MAX_FIELD_OF_VIEW,
PAN_SCALE,
YAW_RANGE_HALF,
PITCH_RANGE_HALF,
CIRCULAR_PITCH_RANGE_HALF,
CONTROL_MODE_VR,
CONTROL_MODE_YAWPITCH,
TOUCH_DIRECTION_NONE,
} from "./consts";
import {VERSION} from "../version";
const DEFAULT_YAW_RANGE = [-YAW_RANGE_HALF, YAW_RANGE_HALF];
const DEFAULT_PITCH_RANGE = [-PITCH_RANGE_HALF, PITCH_RANGE_HALF];
const CIRCULAR_PITCH_RANGE = [-CIRCULAR_PITCH_RANGE_HALF, CIRCULAR_PITCH_RANGE_HALF];
/**
* A module used to provide coordinate based on yaw/pitch orientation. This module receives user touch action, keyboard, mouse and device orientation(if it exists) as input, then combines them and converts it to yaw/pitch coordinates.
*
* @alias eg.YawPitchControl
* @extends eg.Component
*
* @support {"ie": "10+", "ch" : "latest", "ff" : "latest", "sf" : "latest", "edge" : "latest", "ios" : "7+", "an" : "2.3+ (except 3.x)"}
*/
export default class YawPitchControl extends Component {
static VERSION = VERSION;
// Expose DeviceOrientationControls sub module for test purpose
static CONTROL_MODE_VR = CONTROL_MODE_VR;
static CONTROL_MODE_YAWPITCH = CONTROL_MODE_YAWPITCH;
static TOUCH_DIRECTION_ALL = TOUCH_DIRECTION_ALL;
static TOUCH_DIRECTION_YAW = TOUCH_DIRECTION_YAW;
static TOUCH_DIRECTION_PITCH = TOUCH_DIRECTION_PITCH;
static TOUCH_DIRECTION_NONE = TOUCH_DIRECTION_NONE;
/**
* @param {Object} options The option object of the eg.YawPitch module
* @param {Element}[options.element=null] element A base element for the eg.YawPitch module
* @param {Number} [options.yaw=0] initial yaw (degree)
* @param {Number} [options.pitch=0] initial pitch (degree)
* @param {Number} [options.fov=65] initial field of view (degree)
* @param {Boolean} [optiosn.showPolePoint=true] Indicates whether pole is shown
* @param {Boolean} [options.useZoom=true] Indicates whether zoom is available
* @param {Boolean} [options.useKeyboard=true] Indicates whether keyboard is enabled
* @param {String} [config.gyroMode=yawPitch] Enables control through device motion.
* @param {Number} [options.touchDirection=TOUCH_DIRECTION_ALL] Direction of the touch movement (TOUCH_DIRECTION_ALL: all, TOUCH_DIRECTION_YAW: horizontal, TOUCH_DIRECTION_PITCH: vertical, TOUCH_DIRECTION_NONE: no move)
* @param {Array} [options.yawRange=[-180, 180] Range of visible yaw
* @param {Array} [options.pitchRange=[-90, 90] Range of visible pitch
* @param {Array} [options.fovRange=[30, 110] Range of FOV
* @param {Number} [options.aspectRatio=1] Aspect Ratio
*/
constructor(options) {
super();
const opt = Object.assign({
element: null,
yaw: 0,
pitch: 0,
fov: 65,
showPolePoint: false,
useZoom: true,
useKeyboard: true,
gyroMode: GYRO_MODE.YAWPITCH,
touchDirection: TOUCH_DIRECTION_ALL,
yawRange: DEFAULT_YAW_RANGE,
pitchRange: DEFAULT_PITCH_RANGE,
fovRange: [30, 110],
aspectRatio: 1, /* TODO: Need Mandatory? */
}, options);
this._element = opt.element;
this._initialFov = opt.fov;
this._enabled = false;
this._isAnimating = false;
this._deviceQuaternion = null;
this._initAxes(opt);
this.option(opt);
}
_initAxes(opt) {
const yRange = this._updateYawRange(opt.yawRange, opt.fov, opt.aspectRatio);
const pRange = this._updatePitchRange(opt.pitchRange, opt.fov, opt.showPolePoint);
const useRotation = opt.gyroMode === GYRO_MODE.VR;
this.axesPanInput = new RotationPanInput(this._element, {useRotation});
this.axesWheelInput = new WheelInput(this._element, {scale: -4});
this.axesTiltMotionInput = null;
this.axesPinchInput = SUPPORT_TOUCH ? new PinchInput(this._element, {scale: -1}) : null;
this.axesMoveKeyInput = new MoveKeyInput(this._element, {scale: [-6, 6]});
this.axes = new Axes({
yaw: {
range: yRange,
circular: YawPitchControl.isCircular(yRange),
bounce: [0, 0]
},
pitch: {
range: pRange,
circular: YawPitchControl.isCircular(pRange),
bounce: [0, 0]
},
fov: {
range: opt.fovRange,
circular: [false, false],
bounce: [0, 0]
},
}, {
deceleration: MC_DECELERATION,
maximumDuration: MC_MAXIMUM_DURATION,
}, {
yaw: opt.yaw,
pitch: opt.pitch,
fov: opt.fov
}).on({
hold: evt => {
// Restore maximumDuration not to be spin too mush.
this.axes.options.maximumDuration = MC_MAXIMUM_DURATION;
this.trigger("hold", {isTrusted: evt.isTrusted});
},
change: evt => {
if (evt.delta.fov !== 0) {
this._updateControlScale(evt);
this.updatePanScale();
}
this._triggerChange(evt);
},
release: evt => {
this._triggerChange(evt);
},
animationStart: evt => {
},
animationEnd: evt => {
this.trigger("animationEnd", {isTrusted: evt.isTrusted});
},
});
}
/**
* Update Pan Scale
*
* Scale(Sensitivity) values of panning is related with fov and height.
* If at least one of them is changed, this function need to be called.
* @param {*} param
*/
updatePanScale(param = {}) {
const fov = this.axes.get().fov;
const areaHeight = param.height || parseInt(getComputedStyle(this._element).height, 10);
const scale = MC_BIND_SCALE[0] * fov / this._initialFov * PAN_SCALE / areaHeight;
this.axesPanInput.options.scale = [scale, scale];
this.axes.options.deceleration = MC_DECELERATION * fov / MAX_FIELD_OF_VIEW;
return this;
}
/*
* Override component's option method
* to call method for updating values which is affected by option change.
*
* @param {*} args
*/
option(...args) {
const argLen = args.length;
// Getter
if (argLen === 0) {
return this._getOptions();
} else if (argLen === 1 && typeof args[0] === "string") {
return this._getOptions(args[0]);
}
// Setter
const beforeOptions = Object.assign({}, this.options);
let newOptions = {};
let changedKeyList = []; // TODO: if value is not changed, then do not push on changedKeyList.
if (argLen === 1) {
changedKeyList = Object.keys(args[0]);
newOptions = Object.assign({}, args[0]);
} else if (argLen >= 2) {
changedKeyList.push(args[0]);
newOptions[args[0]] = args[1];
}
this._setOptions(this._getValidatedOptions(newOptions));
this._applyOptions(changedKeyList, beforeOptions);
return this;
}
_getValidatedOptions(newOptions) {
if (newOptions.yawRange) {
newOptions.yawRange =
this._getValidYawRange(newOptions.yawRange, newOptions.fov, newOptions.aspectRatio);
}
if (newOptions.pitchRange) {
newOptions.pitchRange = this._getValidPitchRange(newOptions.pitchRange, newOptions.fov);
}
return newOptions;
}
_getOptions(key) {
let value;
if (typeof key === "string") {
value = this.options[key];
} else if (arguments.length === 0) {
value = this.options;
}
return value;
}
_setOptions(options) {
for (const key in options) {
this.options[key] = options[key];
}
}
_applyOptions(keys, prevOptions) {
// If one of below is changed, call updateControlScale()
if (keys.some(key =>
key === "showPolePoint" || key === "fov" || key === "aspectRatio" ||
key === "yawRange" || key === "pitchRange"
)) {
this._updateControlScale();
// If fov is changed, update pan scale
if (keys.indexOf("fov") >= 0) {
this.updatePanScale();
}
}
if (keys.some(key => key === "fovRange")) {
/**
* Temporary Fix Code
* Changed float number as toFixed(5) format for temporary.
*
* TODO: it should not use toFixed(5) after axes.js is fixed.
*/
const fovRange = this.options.fovRange.map(v => +v.toFixed(5));
const prevFov = this.axes.get().fov;
let nextFov = this.axes.get().fov;
vec2.copy(this.axes.axis.fov.range, fovRange);
if (nextFov < fovRange[0]) {
nextFov = fovRange[0];
} else if (prevFov > fovRange[1]) {
nextFov = fovRange[1];
}
if (prevFov !== nextFov) {
this.axes.setTo({
fov: nextFov
}, 0);
this._updateControlScale();
this.updatePanScale();
}
}
if (keys.some(key => key === "gyroMode") && SUPPORT_DEVICEMOTION) {
const isVR = this.options.gyroMode === GYRO_MODE.VR;
const isYawPitch = this.options.gyroMode === GYRO_MODE.YAWPITCH;
// Disconnect first
if (this.axesTiltMotionInput) {
this.axes.disconnect(this.axesTiltMotionInput);
this.axesTiltMotionInput.destroy();
this.axesTiltMotionInput = null;
}
if (this._deviceQuaternion) {
this._deviceQuaternion.destroy();
this._deviceQuaternion = null;
}
if (isVR) {
this._initDeviceQuaternion();
} else if (isYawPitch) {
this.axesTiltMotionInput = new TiltMotionInput(this._element);
this.axes.connect(["yaw", "pitch"], this.axesTiltMotionInput);
}
this.axesPanInput.setUseRotation(isVR);
}
if (keys.some(key => key === "useKeyboard")) {
const useKeyboard = this.options.useKeyboard;
if (useKeyboard) {
this.axes.connect(["yaw", "pitch"], this.axesMoveKeyInput);
} else {
this.axes.disconnect(this.axesMoveKeyInput);
}
}
if (keys.some(key => key === "useZoom")) {
const useZoom = this.options.useZoom;
// Disconnect first
this.axes.disconnect(this.axesWheelInput);
if (useZoom) {
this.axes.connect(["fov"], this.axesWheelInput);
}
}
this._togglePinchInputByOption(this.options.touchDirection, this.options.useZoom);
if (keys.some(key => key === "touchDirection")) {
this._enabled && this._enableTouch(this.options.touchDirection);
}
}
_togglePinchInputByOption(touchDirection, useZoom) {
if (this.axesPinchInput) {
// disconnect first
this.axes.disconnect(this.axesPinchInput);
// If the touchDirection option is not ALL, pinchInput should be disconnected to make use of a native scroll.
if (
useZoom &&
touchDirection === TOUCH_DIRECTION_ALL &&
// TODO: Get rid of using private property of axes instance.
this.axes._inputs.indexOf(this.axesPinchInput) === -1
) {
this.axes.connect(["fov"], this.axesPinchInput);
}
}
}
_enableTouch(direction) {
// Disconnect first
this.axesPanInput && this.axes.disconnect(this.axesPanInput);
const yawEnabled = direction & TOUCH_DIRECTION_YAW ? "yaw" : null;
const pitchEnabled = direction & TOUCH_DIRECTION_PITCH ? "pitch" : null;
this.axes.connect([yawEnabled, pitchEnabled], this.axesPanInput);
}
_initDeviceQuaternion() {
this._deviceQuaternion = new DeviceQuaternion();
this._deviceQuaternion.on("change", e => {
this._triggerChange(e);
});
}
_getValidYawRange(newYawRange, newFov, newAspectRatio) {
const ratio = YawPitchControl.adjustAspectRatio(newAspectRatio || this.options.aspectRatio || 1);
const fov = newFov || this.axes.get().fov;
const horizontalFov = fov * ratio;
const isValid = newYawRange[1] - newYawRange[0] >= horizontalFov;
if (isValid) {
return newYawRange;
} else {
return this.options.yawRange || DEFAULT_YAW_RANGE;
}
}
_getValidPitchRange(newPitchRange, newFov) {
const fov = newFov || this.axes.get().fov;
const isValid = newPitchRange[1] - newPitchRange[0] >= fov;
if (isValid) {
return newPitchRange;
} else {
return this.options.pitchRange || DEFAULT_PITCH_RANGE;
}
}
static isCircular(range) {
return range[1] - range[0] < 360 ? [false, false] : [true, true];
}
/**
* Update yaw/pitch min/max by 5 factor
*
* 1. showPolePoint
* 2. fov
* 3. yawRange
* 4. pitchRange
* 5. aspectRatio
*
* If one of above is changed, call this function
*/
_updateControlScale(changeEvt) {
const opt = this.options;
const fov = this.axes.get().fov;
const pRange = this._updatePitchRange(opt.pitchRange, fov, opt.showPolePoint);
const yRange = this._updateYawRange(opt.yawRange, fov, opt.aspectRatio);
// TODO: If not changed!?
const pos = this.axes.get();
let y = pos.yaw;
let p = pos.pitch;
vec2.copy(this.axes.axis.yaw.range, yRange);
vec2.copy(this.axes.axis.pitch.range, pRange);
this.axes.axis.yaw.circular = YawPitchControl.isCircular(yRange);
this.axes.axis.pitch.circular = YawPitchControl.isCircular(pRange);
/**
* update yaw/pitch by it's range.
*/
if (y < yRange[0]) {
y = yRange[0];
} else if (y > yRange[1]) {
y = yRange[1];
}
if (p < pRange[0]) {
p = pRange[0];
} else if (p > pRange[1]) {
p = pRange[1];
}
if (changeEvt) {
changeEvt.set({
yaw: y,
pitch: p,
});
}
this.axes.setTo({
yaw: y,
pitch: p,
}, 0);
return this;
}
_updatePitchRange(pitchRange, fov, showPolePoint) {
if (this.options.gyroMode === GYRO_MODE.VR) {
// Circular pitch on VR
return CIRCULAR_PITCH_RANGE;
}
const verticalAngle = pitchRange[1] - pitchRange[0];
const halfFov = fov / 2;
const isPanorama = verticalAngle < 180;
if (showPolePoint && !isPanorama) {
// Use full pinch range
return pitchRange.map(v => +v.toFixed(5));
}
// Round value as movableCood do.
return [pitchRange[0] + halfFov, pitchRange[1] - halfFov].map(v => +v.toFixed(5));
}
_updateYawRange(yawRange, fov, aspectRatio) {
if (this.options.gyroMode === GYRO_MODE.VR) {
return DEFAULT_YAW_RANGE;
}
const horizontalAngle = yawRange[1] - yawRange[0];
/**
* Full 360 Mode
*/
if (horizontalAngle >= 360) {
// Don't limit yaw range on Full 360 mode.
return yawRange.map(v => +v.toFixed(5));
}
/**
* Panorama mode
*/
let MAGIC_NUMBER = 1;
const ratio = YawPitchControl.adjustAspectRatio(aspectRatio);
const halfHorizontalFov = fov / 2 * ratio;
// TODO: Magic Number Fix!
if (horizontalAngle > 290) {
MAGIC_NUMBER = 0.794;// horizontalAngle = 286;
} else if (horizontalAngle > 125) {
MAGIC_NUMBER = 0.98; // horizontalAngle *= 0.98;
}
// Round value as movableCood do.
return [
(yawRange[0] * MAGIC_NUMBER) + halfHorizontalFov,
(yawRange[1] * MAGIC_NUMBER) - halfHorizontalFov
].map(v => +v.toFixed(5));
}
_triggerChange(evt) {
const pos = this.axes.get();
const opt = this.options;
const event = {
targetElement: opt.element,
isTrusted: evt.isTrusted,
};
event.yaw = pos.yaw;
event.pitch = pos.pitch;
event.fov = pos.fov;
if (opt.gyroMode === GYRO_MODE.VR && this._deviceQuaternion) {
event.quaternion = this._deviceQuaternion.getCombinedQuaternion(pos.yaw, pos.pitch);
}
this.trigger("change", event);
}
// TODO: makes constant to be logic
static adjustAspectRatio(input) {
const inputRange = [
0.520, 0.540, 0.563, 0.570, 0.584, 0.590, 0.609, 0.670,
0.702, 0.720, 0.760, 0.780, 0.820, 0.920, 0.970, 1.00, 1.07, 1.14, 1.19,
1.25, 1.32, 1.38, 1.40, 1.43, 1.53, 1.62, 1.76, 1.77, 1.86, 1.96, 2.26,
2.30, 2.60, 3.00, 5.00, 6.00
];
const outputRange = [
0.510, 0.540, 0.606, 0.560, 0.628, 0.630, 0.647, 0.710,
0.736, 0.757, 0.780, 0.770, 0.800, 0.890, 0.975, 1.00, 1.07, 1.10, 1.15,
1.18, 1.22, 1.27, 1.30, 1.33, 1.39, 1.45, 1.54, 1.55, 1.58, 1.62, 1.72,
1.82, 1.92, 2.00, 2.24, 2.30
];
let rangeIdx = -1;
for (let i = 0; i < inputRange.length - 1; i++) {
if (inputRange[i] <= input && inputRange[i + 1] >= input) {
rangeIdx = i;
break;
}
}
if (rangeIdx === -1) {
if (inputRange[0] > input) {
return outputRange[0];
} else {
return outputRange[outputRange[0].length - 1];
}
}
const inputA = inputRange[rangeIdx];
const inputB = inputRange[rangeIdx + 1];
const outputA = outputRange[rangeIdx];
const outputB = outputRange[rangeIdx + 1];
return YawPitchControl.lerp(outputA, outputB, (input - inputA) / (inputB - inputA));
}
static lerp(a, b, fraction) {
return a + fraction * (b - a);
}
/**
* Enable YawPitch functionality
*
* @method eg.YawPitch#enable
*/
enable() {
if (this._enabled) {
return this;
}
this._enabled = true;
// touchDirection is decided by parameter is valid string (Ref. Axes.connect)
this._applyOptions(Object.keys(this.options), this.options);
// TODO: Is this code is needed? Check later.
this.updatePanScale();
return this;
}
/**
* Disable YawPitch functionality
*
* @method eg.YawPitch#disable
*/
disable(persistOrientation) {
if (!this._enabled) {
return this;
}
// TODO: Check peristOrientation is needed!
if (!persistOrientation) {
this._resetOrientation();
}
this.axes.disconnect();
this._enabled = false;
return this;
}
_resetOrientation() {
const opt = this.options;
this.axes.setTo({
yaw: opt.yaw,
pitch: opt.pitch,
fov: opt.fov,
}, 0);
return this;
}
/**
* Set one or more of yaw, pitch, fov
*
* @param {Object} coordinate yaw, pitch, fov
* @param {Number} duration Animation duration. if it is above 0 then it's animated.
*/
lookAt({yaw, pitch, fov}, duration) {
const pos = this.axes.get();
const y = yaw === undefined ? 0 : yaw - pos.yaw;
const p = pitch === undefined ? 0 : pitch - pos.pitch;
const f = fov === undefined ? 0 : fov - pos.fov;
// Allow duration of animation to have more than MC_MAXIMUM_DURATION.
this.axes.options.maximumDuration = Infinity;
this.axes.setBy({
yaw: y,
pitch: p,
fov: f
}, duration);
}
get() {
return this.axes.get();
}
getYaw() {
return this.axes.get().yaw;
}
getPitch() {
return this.axes.get().pitch;
}
getFov() {
return this.axes.get().fov;
}
/**
* Destroys objects
*/
destroy() {
this.axes && this.axes.destroy();
this.axisPanInput && this.axisPanInput.destroy();
this.axesWheelInput && this.axesWheelInput.destroy();
this.axesTiltMotionInput && this.axesTiltMotionInput.destroy();
this.axesDeviceOrientationInput && this.axesDeviceOrientationInput.destroy();
this.axesPinchInput && this.axesPinchInput.destroy();
this.axesMoveKeyInput && this.axesMoveKeyInput.destroy();
this._deviceQuaternion && this._deviceQuaternion.destroy();
}
}