/*! radar.js Created by David Rowe on 19 Nov 2017. Copyright 2017-2020 David Rowe. Information: http://ctrlaltstudio.com/vircadia/radar Distributed under the Apache License, Version 2.0. See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html */ /* global EventBridge */ /* eslint-env browser */ (function () { "use strict"; var APP_VERSION = "2.3.0", INSTRUMENT = false, teleportSearchTimestamp, updateAvatarsTimestamp, // Controls. RADAR_RANGE_DEFAULT = 20, // EventBridge ID. SCRIPT_ID = "cas.radar", // EventBridge messages. READY_MESSAGE = "readyMessage", // Engine <=> Dialog VERSIONS_MESSAGE = "versionsMessage", // Engine <== Dialog GET_CONTROLS_MESSAGE = "getControlsMessage", // Engine <== Dialog SET_CONTROLS_MESSAGE = "setControlsMessage", // Engine <=> Dialog GET_SETTINGS_MESSAGE = "getSettingsMessage", // Engine <== Dialog SET_SETTINGS_MESSAGE = "setSettingsMessage", // Engine <=> Dialog ROTATION_MESSAGE = "rotationMessage", // Engine ==> Dialog AVATARS_MESSAGE = "avatarsMessage", // Engine <=> Dialog - World coordinates relative to camera. CLEAR_MESSAGE = "clearMessage", // Engine ==> Dialog TELEPORT_MESSAGE = "teleportMessage", // Engine <== Dialog - World coordinates relative to camera. OPEN_URL_MESSAGE = "openURLMessage", // Engine <== Dialog LOG_MESSAGE = "logMessage", // Engine <== Dialog // Application objects. Communications, Menu, Radar, Controls, SettingsDialog, HelpDialog, Controller, isHMDMode = false, //INFO = "INFO:", WARNING = "WARNING:", ERROR = "ERROR:", ERROR_MISSING_CASE = "Missing case:", ERROR_REINITIALIZATION = "Reinitialization"; //#region Utilities ======================================================================================================== function log() { var i, length, strings = []; for (i = 0, length = arguments.length; i < length; i++) { strings.push(arguments[i]); } Communications.log(strings.join(" ")); } //#endregion //#region Communications =================================================================================================== Communications = (function () { // Communications with the main script. var isEventBridgeConnected = false; function log(message) { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: LOG_MESSAGE, message: message })); } function sendControls(controls) { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: SET_CONTROLS_MESSAGE, controls: controls })); } function sendSettings(settings) { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: SET_SETTINGS_MESSAGE, settings: settings })); } function teleportBy(vector, isAvatar) { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: TELEPORT_MESSAGE, vector: vector, isAvatar: isAvatar })); } function checkVersions() { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: VERSIONS_MESSAGE, scriptVersion: APP_VERSION, htmlVersion: document.getElementById("version").innerHTML })); } function requestControls() { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: GET_CONTROLS_MESSAGE })); } function requestSettings() { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: GET_SETTINGS_MESSAGE })); } function openURL(url) { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: OPEN_URL_MESSAGE, url: url })); } function refreshAvatars() { EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: AVATARS_MESSAGE })); } function onScriptEventReceived(data) { var message; try { message = JSON.parse(data); } catch (e) { return; } if (message.id !== SCRIPT_ID) { return; } switch (message.type) { case READY_MESSAGE: isEventBridgeConnected = true; isHMDMode = message.hmd; break; case ROTATION_MESSAGE: case AVATARS_MESSAGE: case CLEAR_MESSAGE: case SET_CONTROLS_MESSAGE: case SET_SETTINGS_MESSAGE: // Nothing to do. break; default: log(ERROR, ERROR_MISSING_CASE, 100, message); } Controller.onMessageReceived(message); } function setUp() { // Set up even bridge. // The EventBridge is not always completely available straight away. var SETUP_RETRY_DELAY = 500; if (!isEventBridgeConnected) { EventBridge.scriptEventReceived.connect(onScriptEventReceived); EventBridge.emitWebEvent(JSON.stringify({ id: SCRIPT_ID, type: READY_MESSAGE })); setTimeout(setUp, SETUP_RETRY_DELAY); } } function tearDown() { // Disconnect event bridge. EventBridge.scriptEventReceived.disconnect(onScriptEventReceived); } return { checkVersions: checkVersions, requestControls: requestControls, requestSettings: requestSettings, sendControls: sendControls, refreshAvatars: refreshAvatars, sendSettings: sendSettings, openURL: openURL, teleportBy: teleportBy, log: log, setUp: setUp, tearDown: tearDown }; }()); //#endregion //#region Radar ============================================================================================================ Radar = (function () { // The radar display circle in the main window. var radarRange = RADAR_RANGE_DEFAULT, RADAR_RANGE_SCALE = 1.02, // Don't display avatar dots outside circle. radarRangeScale = RADAR_RANGE_SCALE * radarRange, RADAR_CIRCLE_RADIUS = 210, // Reflects value in CSS. radarCircleDisplay, radarCircleRotation, DEG_TO_RAD = Math.PI / 180, avatarData, avatarDataProcessIndex, avatarDots = {}, avatarDotsCounter = 0, // Lightweight stand-in for a time stamp. DOT_RADIUS = 4, // Reflects value in CSS. hoveredDot = null, avatarLabel, isAvatarLabelVisible = false, isPersistingAvatarLabel = false, isMouseOverDot = false, isMouseOverLabel = false, uuidForLabel = null, dotForLabel = null, radarCircleOverlay, isIgnoreCircleAction = true, MAX_CLICK_DURATION = 500, radarCircleCentre, teleportCircle, TELEPORT_CIRCLE_RADIUS = 41, // Reflects value in CSS. teleportCircleOffset, isTeleporting = false, teleportingTimer = null, teleportSearchContext = {}, teleportSearchRadius, highlightedAvatarIndexes = [], updateAvatarDataTimer = null, avatarCount, radarScaleLeft, radarScaleRight, isRunning = false; //#region Avatar Label ------------------------------------------------------------------------------------------------- function showAvatarLabel(avatarDot) { uuidForLabel = avatarDot.uuid; dotForLabel = avatarDot.dot; dotForLabel.appendChild(avatarLabel); dotForLabel.classList.add("labeled"); avatarLabel.innerHTML = avatarDot.name; avatarLabel.style.display = "block"; avatarLabel.style.marginLeft = (-avatarLabel.offsetWidth / 2 + DOT_RADIUS) + "px"; isAvatarLabelVisible = true; } function hideAvatarLabel() { avatarLabel.style.display = "none"; uuidForLabel = null; if (dotForLabel) { dotForLabel.classList.remove("labeled"); } dotForLabel = null; isAvatarLabelVisible = false; } function onMouseEnterDot(event) { var uuid; if (isMouseOverLabel) { // Mouse hasn't entered the actual dot; it has entered the label. return; } isMouseOverDot = true; uuid = event.target.getAttribute("uuid"); hoveredDot = avatarDots[uuid]; if (!hoveredDot) { // Should never happen but handle just in case. hideAvatarLabel(); return; } if (!isPersistingAvatarLabel) { hoveredDot.dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; showAvatarLabel(hoveredDot); } } function onMouseLeaveDot() { if (!isMouseOverDot) { // Mouse wasn't over actual dot; it was over label. return; } isMouseOverDot = false; hoveredDot = null; if (isAvatarLabelVisible && !isPersistingAvatarLabel) { hideAvatarLabel(); } } function onMouseOverLabel() { if (!isPersistingAvatarLabel) { // Moved onto the label from the dot. hideAvatarLabel(); } else { isMouseOverLabel = true; } } function onMouseOutLabel() { isMouseOverLabel = false; } function doRadarCircleOverlayClick() { if (isMouseOverDot) { // Clicked dot. if (isPersistingAvatarLabel) { if (hoveredDot.uuid === uuidForLabel) { // Hide label for current dot. isPersistingAvatarLabel = false; hideAvatarLabel(); } else { // Transfer label to a new dot. hoveredDot.dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; showAvatarLabel(hoveredDot); } } else { if (!isAvatarLabelVisible) { // Handle label being hidden because just hid for the current dot. hoveredDot.dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; showAvatarLabel(hoveredDot); } isPersistingAvatarLabel = true; } } else { // Clicked radar circle background. if (isAvatarLabelVisible && isPersistingAvatarLabel) { hideAvatarLabel(); isPersistingAvatarLabel = false; } } } //#endregion //#region Teleport ----------------------------------------------------------------------------------------------------- function calculateRadarVector(x, y) { // Calculate radar-relative x, z coordinates from screen x, y. var deltaX = x - radarCircleCentre.x, deltaY = radarCircleCentre.y - y, distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY), angle = Math.atan2(deltaY / distance, deltaX / distance); angle = angle + radarCircleRotation * DEG_TO_RAD; distance = distance * radarRangeScale / RADAR_CIRCLE_RADIUS; return { x: distance * Math.cos(angle), y: 0, z: -distance * Math.sin(angle) }; } function setTeleportSearchRadius() { // Avatar dots must be wholly within the teleport search circle, not just touching. teleportSearchRadius = (TELEPORT_CIRCLE_RADIUS - DOT_RADIUS) / RADAR_CIRCLE_RADIUS * radarRangeScale; } function highlightAvatarDots(avatarIndexes) { var i, length; // Remove old highlights. for (i = highlightedAvatarIndexes.length - 1; i >= 0; i -= 1) { if (avatarIndexes.indexOf(highlightedAvatarIndexes[i]) === -1) { avatarDots[avatarData[highlightedAvatarIndexes[i]].uuid].dot.classList.remove("highlighted"); highlightedAvatarIndexes.splice(i, 1); } } // Add new highlights. for (i = 0, length = avatarIndexes.length; i < length; i += 1) { if (highlightedAvatarIndexes.indexOf(avatarIndexes[i]) === -1) { avatarDots[avatarData[avatarIndexes[i]].uuid].dot.classList.add("highlighted"); highlightedAvatarIndexes.push(avatarIndexes[i]); } } } function doTeleportSearch() { var index, finishIndex, avatarPosition, targetVector, deltaVector, distanceSquared, closestDistanceSquared, teleportSearchRadiusSquared, closestAvatarIndex = -1, avatarsInCircle = [], targetElevation, MAX_ELEVATION_DIFFERENCE = 1.0, avatarsToHighlight = [], i, length; if (avatarData.length === 0) { return; } if (INSTRUMENT) { teleportSearchTimestamp = Date.now(); } index = Math.min(teleportSearchContext.currentIndex, avatarData.length - 1); finishIndex = Math.min(teleportSearchContext.finishIndex, avatarData.length - 1); // Find horizontally-closest avatar within teleport search circle. targetVector = calculateRadarVector(teleportSearchContext.targetPosition.x, teleportSearchContext.targetPosition.y); teleportSearchRadiusSquared = teleportSearchRadius * teleportSearchRadius; closestDistanceSquared = 2 * radarRange * radarRange; do { index = (index + 1) % avatarData.length; avatarPosition = avatarData[index].vector; deltaVector = { x: targetVector.x - avatarPosition.x, z: targetVector.z - avatarPosition.z }; // Use squared distances to save a Math.sqrt() call each loop. distanceSquared = deltaVector.x * deltaVector.x + deltaVector.z * deltaVector.z; if (distanceSquared < teleportSearchRadiusSquared) { avatarsInCircle.push(index); if (distanceSquared < closestDistanceSquared) { closestDistanceSquared = distanceSquared; closestAvatarIndex = index; } } } while (index !== finishIndex); if (closestAvatarIndex !== -1) { teleportSearchContext.closestAvatarData = avatarData[closestAvatarIndex]; targetElevation = avatarData[closestAvatarIndex].vector.y; for (i = 0, length = avatarsInCircle.length; i < length; i += 1) { if (Math.abs(avatarData[avatarsInCircle[i]].vector.y - targetElevation) <= MAX_ELEVATION_DIFFERENCE) { avatarsToHighlight.push(avatarsInCircle[i]); } } } else { teleportSearchContext.closestAvatarData = null; targetElevation = null; } teleportSearchContext.closestAvatarIndex = closestAvatarIndex; teleportSearchContext.teleportVector = { x: targetVector.x, y: targetElevation, z: targetVector.z }; teleportSearchContext.currentIndex = index; highlightAvatarDots(avatarsToHighlight); if (INSTRUMENT) { log("HTML script : teleport search : " + (Date.now() - teleportSearchTimestamp)); // 2019 01 23 // Simulation: // - 100 avatars: 0ms // - 1000 avatars: 1ms // - 10000 avatars: 3ms // Conclusion: Don't need to split into chunks. } } function startTeleportSearch(x, y) { // Start searching for elevation of avatar closest to x, y display position. // Searches in a circular pass through avatarData starting at the start of avatarData. isTeleporting = true; teleportCircle.style.display = "block"; teleportSearchContext = { targetPosition: { x: x, y: y }, closestAvatarIndex: -1, closestAvatarData: null, teleportVector: { x: 0, y: 0, z: 0 }, avatarsAtElevation: [], currentIndex: -1, finishIndex: avatarData.length - 1 }; doTeleportSearch(); } function updateTeleportSearch(x, y) { // Update x, y display position that search is being done for. // Continue searching in a circular pass through avatarData starting at current search index. teleportSearchContext.targetPosition = { x: x, y: y }; teleportSearchContext.finishIndex = Math.min(teleportSearchContext.currentIndex, avatarData.length - 1); doTeleportSearch(); } function finishTeleportSearch() { // Finish searching for elevation of avatar closest to x, y display position. isTeleporting = false; teleportCircle.style.display = "none"; highlightAvatarDots([]); } function refreshTeleportSearch() { // Update search results. // Some of the new avatarData may have already been processed but don't worry about that. teleportSearchContext.finishIndex = Math.min(teleportSearchContext.currentIndex, avatarData.length - 1); doTeleportSearch(); } function doRadarCircleOverlayTeleport() { // Teleports to near target position if mouse is over an avatar dot. Communications.teleportBy(teleportSearchContext.teleportVector, isMouseOverDot); } function calcRadarCircleCentre() { var radarCircle = document.getElementById("radar-circle"); radarCircleCentre = { x: radarCircle.offsetLeft + RADAR_CIRCLE_RADIUS, y: radarCircle.offsetTop + RADAR_CIRCLE_RADIUS }; teleportCircleOffset = { x: -radarCircleCentre.x + RADAR_CIRCLE_RADIUS - TELEPORT_CIRCLE_RADIUS, y: -radarCircleCentre.y + RADAR_CIRCLE_RADIUS - TELEPORT_CIRCLE_RADIUS }; } function isPointInRadarCircle(x, y) { var deltaX = x - radarCircleCentre.x, deltaY = y - radarCircleCentre.y; return Math.sqrt(deltaX * deltaX + deltaY * deltaY) <= RADAR_CIRCLE_RADIUS; } function updateTeleportCirclePosition(x, y) { teleportCircle.style.left = (x + teleportCircleOffset.x).toString() + "px"; teleportCircle.style.top = (y + teleportCircleOffset.y).toString() + "px"; } function handlePressOnRadarCircleOverlay(x, y) { if (!isPointInRadarCircle(x, y)) { isIgnoreCircleAction = true; return; } isIgnoreCircleAction = false; teleportingTimer = setTimeout(function () { teleportingTimer = null; startTeleportSearch(x, y); updateTeleportCirclePosition(x, y); }, MAX_CLICK_DURATION); } function handleMoveOnRadarCircleOverlay(x, y) { if (isIgnoreCircleAction) { return; } if (!isPointInRadarCircle(x, y)) { isIgnoreCircleAction = true; if (isTeleporting) { finishTeleportSearch(); } } if (isTeleporting) { updateTeleportSearch(x, y); updateTeleportCirclePosition(x, y); } } function handleLeaveOnRadarCircleOverlay() { isIgnoreCircleAction = true; if (isTeleporting) { finishTeleportSearch(); } } function handleReleaseOnRadarCircleOverlay() { if (isIgnoreCircleAction) { return; } if (isTeleporting) { finishTeleportSearch(); doRadarCircleOverlayTeleport(); } else { clearTimeout(teleportingTimer); teleportingTimer = null; doRadarCircleOverlayClick(); } } function onMouseDownOnRadarCircleOverlay(event) { if (!isHMDMode) { handlePressOnRadarCircleOverlay(event.x, event.y); } } function onMouseMoveOnRadarCircleOverlay(event) { if (!isHMDMode) { handleMoveOnRadarCircleOverlay(event.x, event.y); } } function onMouseLeaveOnRadarCircleOverlay() { if (!isHMDMode) { handleLeaveOnRadarCircleOverlay(); } } function onMouseUpRadarOnCircleOverlay() { if (!isHMDMode) { handleReleaseOnRadarCircleOverlay(); } } function onTouchStartOnRadarCircleOverlay(event) { if (isHMDMode) { handlePressOnRadarCircleOverlay(event.touches[0].clientX, event.touches[0].clientY); } } function onTouchMoveOnRadarCircleOverlay(event) { if (isHMDMode) { handleMoveOnRadarCircleOverlay(event.touches[0].clientX, event.touches[0].clientY); } } function onTouchCancelOnRadarCircleOverlay() { if (isHMDMode) { handleLeaveOnRadarCircleOverlay(); } } function onTouchEndOnRadarCircleOverlay(event) { if (isHMDMode) { handleReleaseOnRadarCircleOverlay(event.changedTouches[0].clientX, event.changedTouches[0].clientY); } } //#endregion //#region Avatar Dots -------------------------------------------------------------------------------------------------- function maybeAddDot(uuid, name) { var dot, avatarDot; if (!avatarDots.hasOwnProperty(uuid)) { dot = document.createElement("div"); dot.className = "dot"; dot.setAttribute("uuid", uuid); dot.addEventListener("mouseenter", onMouseEnterDot); dot.addEventListener("mouseleave", onMouseLeaveDot); radarCircleDisplay.appendChild(dot); avatarDots[uuid] = { dot: dot, uuid: uuid }; } avatarDot = avatarDots[uuid]; avatarDot.name = name; // User may have updated their display name so update our copy. avatarDot.counter = avatarDotsCounter; return avatarDot.dot; } function removeExtraDots() { var uuid; for (uuid in avatarDots) { if (avatarDots[uuid].counter < avatarDotsCounter) { radarCircleDisplay.removeChild(avatarDots[uuid].dot); delete avatarDots[uuid]; } } } function elevationColor(scale) { if (scale > 0) { return "hsl(210,100%," + (100 - scale * 50) + "%)"; // Fade white to sky blue. } return "hsl(0,100%," + (100 + scale * 50) + "%)"; // Fade white to red. } function updateAvatarData(range, data) { var startTime, datum, vector, dot, MAX_LOOP_TIME = 50, RECALL_DELAY = 5; updateAvatarDataTimer = null; if (range) { // Start processing run. if (INSTRUMENT) { updateAvatarsTimestamp = Date.now(); } avatarData = range === radarRange ? data : []; // Ignore data if it's for a previous radar range. avatarDataProcessIndex = 0; avatarCount.innerHTML = avatarData.length.toString(); avatarDotsCounter += 1; } if (INSTRUMENT) { // 2019 01 09: // - Localhost with one avatar recording, no teleport searching, second call seems to happen at ~5000 avatars. log("HTML script : avatars to display : " + (avatarData.length - avatarDataProcessIndex)); } // Yielding loop for process run to allow UI to happen while processing lots of avatar data. startTime = Date.now(); while (avatarDataProcessIndex < avatarData.length > 0 && (Date.now() - startTime) < MAX_LOOP_TIME) { datum = avatarData[avatarDataProcessIndex]; vector = datum.vector; dot = maybeAddDot(datum.uuid, datum.name); dot.style.left = ((1 + vector.x / radarRangeScale) * RADAR_CIRCLE_RADIUS).toString() + "px"; dot.style.top = ((1 + vector.z / radarRangeScale) * RADAR_CIRCLE_RADIUS).toString() + "px"; dot.style.backgroundColor = elevationColor(vector.y / radarRangeScale); if (datum.isMyAvatar) { dot.classList.add("my-dot"); } // Re-add persisted label if dot has reappeared after going off screen. if (isPersistingAvatarLabel && datum.uuid === uuidForLabel) { dot.style.transform = "rotate(" + (-radarCircleRotation) + "deg)"; showAvatarLabel(avatarDots[datum.uuid]); } avatarDataProcessIndex += 1; } if (avatarDataProcessIndex < avatarData.length && isRunning) { // Continue processing run. updateAvatarDataTimer = setTimeout(updateAvatarData, RECALL_DELAY); } else { // Finish processing run. removeExtraDots(); Communications.refreshAvatars(); // Notify main script that have finished displaying. if (INSTRUMENT) { log("HTML script : update avatars : " + (Date.now() - updateAvatarsTimestamp)); // 2019 01 07 // Simulation: // - 100 avatars: 3ms for first call; 1.5ms for subsequent calls. // - 1000 avatars: 20ms for first call; 8ms for subsequent calls. } } if (isTeleporting) { // Update teleport search UI whether or not avatar data has finished being processed. refreshTeleportSearch(); } } function clearAvatarData() { avatarData = []; avatarDotsCounter += 1; removeExtraDots(); } //#endregion //#region Circle ------------------------------------------------------------------------------------------------------ function updateRotation(rotation) { radarCircleDisplay.style.transform = "rotate(" + rotation + "deg)"; if (isPersistingAvatarLabel) { dotForLabel.style.transform = "rotate(" + (-rotation) + "deg)"; } else if (hoveredDot) { hoveredDot.dot.style.transform = "rotate(" + (-rotation) + "deg)"; } radarCircleRotation = rotation; } function setCircleScale() { var scale = radarRange + "m"; scale = scale.replace("000m", ",000m"); radarScaleLeft.innerHTML = scale; radarScaleRight.innerHTML = scale; } //#endregion //#region Controls ----------------------------------------------------------------------------------------------------- function setRange(range) { clearAvatarData(); // Recreates all avatar dots so that they're the right colour and not outside the circle. radarRange = range; radarRangeScale = RADAR_RANGE_SCALE * radarRange; setCircleScale(); setTeleportSearchRadius(); } //#endregion //#region Set up and tear down ----------------------------------------------------------------------------------------- function setUp() { radarCircleDisplay = document.getElementById("radar-circle-display"); radarScaleLeft = document.getElementById("radar-scale-left"); radarScaleRight = document.getElementById("radar-scale-right"); avatarCount = document.getElementById("avatar-count").getElementsByTagName("span")[0]; avatarLabel = document.getElementById("avatar-label"); avatarLabel.addEventListener("mouseover", onMouseOverLabel, true); avatarLabel.addEventListener("mouseout", onMouseOutLabel, true); calcRadarCircleCentre(); radarCircleOverlay = document.getElementById("radar-circle-display"); // Touch events don't occur in desktop mode so use mouse events. radarCircleOverlay.addEventListener("mousedown", onMouseDownOnRadarCircleOverlay); radarCircleOverlay.addEventListener("mousemove", onMouseMoveOnRadarCircleOverlay); radarCircleOverlay.addEventListener("mouseleave", onMouseLeaveOnRadarCircleOverlay); radarCircleOverlay.addEventListener("mouseup", onMouseUpRadarOnCircleOverlay); // Mouse events don't occur the same in HMD mode so use touch events. radarCircleDisplay.addEventListener("touchstart", onTouchStartOnRadarCircleOverlay); radarCircleDisplay.addEventListener("touchmove", onTouchMoveOnRadarCircleOverlay); radarCircleDisplay.addEventListener("touchcancel", onTouchCancelOnRadarCircleOverlay); radarCircleDisplay.addEventListener("touchend", onTouchEndOnRadarCircleOverlay); teleportCircle = document.getElementById("teleport-circle"); isRunning = true; } function tearDown() { if (updateAvatarDataTimer !== null) { clearTimeout(updateAvatarDataTimer); updateAvatarDataTimer = null; } isRunning = false; } //#endregion ----------------------------------------------------------------------------------------------------------- return { updateRotation: updateRotation, updateAvatarData: updateAvatarData, clearAvatarData: clearAvatarData, setRange: setRange, setUp: setUp, tearDown: tearDown }; }()); //#endregion //#region Controls ========================================================================================================= Controls = (function () { // The radar controls in the main window. var controlsForm, controlsChangedCallback, scaleButtons = [], i, length; function setControls(controls) { controlsForm["scale"].value = controls.range.toString(); } function setControlsChangedCallback(callback) { controlsChangedCallback = callback; } function onControlsChanged() { controlsChangedCallback({ range: parseInt(controlsForm["scale"].value) }); } controlsForm = document.getElementById("controls"); scaleButtons = document.getElementsByName("scale"); for (i = 0, length = scaleButtons.length; i < length; i++) { scaleButtons[i].addEventListener("change", onControlsChanged); } return { setControls: setControls, controlsChanged: { connect: setControlsChangedCallback } }; }()); //#endregion //#region Settings Dialog ================================================================================================== SettingsDialog = (function () { // The settings dialog. // - Changes in settings values are communicated immediately they're changed. This enables the user to freely move // between the two dialogs and removed the need for a cancel button. // - The dialog is not automatically closed upon the OK button being pressed. var settingsDialog, settingsChangedCallback, okClickedCallback, inputs, i, length; function setSettings(settings) { settingsDialog["show-own"].value = settings.showOwn.toString(); settingsDialog["refresh-rate"].value = settings.refreshRate.toString(); } function setSettingsChangedCallback(callback) { settingsChangedCallback = callback; } function setOKClickedCallback(callback) { okClickedCallback = callback; } function open() { settingsDialog.classList.add("visible"); } function close() { settingsDialog.classList.remove("visible"); } function onSettingsChanged() { settingsChangedCallback({ showOwn: parseInt(settingsDialog["show-own"].value), refreshRate: parseInt(settingsDialog["refresh-rate"].value) }); } function onOKClicked() { okClickedCallback(); } settingsDialog = document.getElementById("settings"); inputs = document.getElementsByName("show-own"); for (i = 0, length = inputs.length; i < length; i++) { inputs[i].addEventListener("change", onSettingsChanged); } inputs = document.getElementsByName("refresh-rate"); for (i = 0, length = inputs.length; i < length; i++) { inputs[i].addEventListener("change", onSettingsChanged); } document.querySelector("#settings .ok input").addEventListener("click", onOKClicked); return { setSettings: setSettings, settingsChanged: { connect: setSettingsChangedCallback }, okClicked: { connect: setOKClickedCallback }, open: open, close: close }; }()); //#endregion //#region Help Dialog ====================================================================================================== HelpDialog = (function () { // The help dialog. // - The dialog is not automatically closed upon the OK button being pressed. var helpDialog, okClickedCallback, linkClickedCallback; function open() { var links, i, length; links = document.querySelectorAll("a"); for (i = 0, length = links.length; i < length; i++) { links[i].addEventListener("click", function (event) { linkClickedCallback(event.target.href); event.preventDefault(); }); } helpDialog.classList.add("visible"); } function close() { helpDialog.classList.remove("visible"); } function setOKClickedCallback(callback) { okClickedCallback = callback; } function setLinkClickedCallback(callback) { linkClickedCallback = callback; } helpDialog = document.getElementById("help"); document.querySelector("#help .ok input").addEventListener("click", function () { okClickedCallback(); }); return { okClicked: { connect: setOKClickedCallback }, linkClicked: { connect: setLinkClickedCallback }, open: open, close: close }; }()); //#endregion //#region Menu ============================================================================================================= Menu = (function () { // The title bar menu (i.e., settings and help buttons). var settingsChangedCallback, NO_DIALOG = 0, HELP_DIALOG = 1, SETTINGS_DIALOG = 2, currentDialog = NO_DIALOG; function setSettingsChangedCallback(callback) { settingsChangedCallback = callback; SettingsDialog.settingsChanged.connect(settingsChangedCallback); } function onSettingsButtonClick() { switch (currentDialog) { case SETTINGS_DIALOG: SettingsDialog.close(); currentDialog = NO_DIALOG; break; case HELP_DIALOG: HelpDialog.close(); SettingsDialog.open(); currentDialog = SETTINGS_DIALOG; break; case NO_DIALOG: SettingsDialog.open(); currentDialog = SETTINGS_DIALOG; break; default: log(ERROR, ERROR_MISSING_CASE, 200); } } function onHelpButtonClick() { switch (currentDialog) { case SETTINGS_DIALOG: SettingsDialog.close(); HelpDialog.open(); currentDialog = HELP_DIALOG; break; case HELP_DIALOG: HelpDialog.close(); currentDialog = NO_DIALOG; break; case NO_DIALOG: HelpDialog.open(); currentDialog = HELP_DIALOG; break; default: log(ERROR, ERROR_MISSING_CASE, 201); } } function onLinkClick(url) { Communications.openURL(url); } document.getElementById("settings-button").addEventListener("click", onSettingsButtonClick); SettingsDialog.okClicked.connect(onSettingsButtonClick); document.getElementById("help-button").addEventListener("click", onHelpButtonClick); HelpDialog.okClicked.connect(onHelpButtonClick); HelpDialog.linkClicked.connect(onLinkClick); return { settingsChanged: { connect: setSettingsChangedCallback } }; }()); //#endregion //#region Controller ======================================================================================================= Controller = (function () { // The overall application logic. var isControlsInitialized = false, isSettingsInitialized = false, SETTINGS_DELAY = 50; function setControls(controls) { if (isControlsInitialized) { log(WARNING, ERROR_REINITIALIZATION, "A"); } Controls.setControls(controls); Radar.setRange(controls.range); isControlsInitialized = true; } function setSettings(settings) { if (isSettingsInitialized) { log(WARNING, ERROR_REINITIALIZATION, "B"); } SettingsDialog.setSettings(settings); isSettingsInitialized = true; } function onMessageReceived(message) { switch (message.type) { case READY_MESSAGE: Communications.checkVersions(); if (!isControlsInitialized) { Communications.requestControls(); } setTimeout(function () { if (!isSettingsInitialized) { Communications.requestSettings(); } }, SETTINGS_DELAY); break; case SET_CONTROLS_MESSAGE: setControls(message.controls); break; case SET_SETTINGS_MESSAGE: setSettings(message.settings); break; case ROTATION_MESSAGE: Radar.updateRotation(message.rotation); break; case AVATARS_MESSAGE: Radar.updateAvatarData(message.range, message.data); break; case CLEAR_MESSAGE: Radar.clearAvatarData(); break; default: log(ERROR, ERROR_MISSING_CASE, 300, message); } } function onSettingsChanged(settings) { Communications.sendSettings(settings); } function onControlsChanged(controls) { Communications.sendControls(controls); Radar.setRange(controls.range); } function setUp() { Controls.controlsChanged.connect(onControlsChanged); Menu.settingsChanged.connect(onSettingsChanged); } function tearDown() { // Nothing to do. } return { onMessageReceived: onMessageReceived, setUp: setUp, tearDown: tearDown }; }()); //#endregion //#region Set up and tear down ============================================================================================= function onUnload() { // Tear down objects. Communications.tearDown(); Controller.tearDown(); Radar.tearDown(); } function onLoad() { var nodes, i, length; nodes = document.querySelectorAll(".std"); for (i = 0, length = nodes.length; i < length; i++) { nodes[i].parentNode.removeChild(nodes[i]); } // Set up primary objects. Radar.setUp(); Controller.setUp(); Communications.setUp(); // Handle document unload. document.body.onunload = function () { onUnload(); }; } onLoad(); //#endregion }());