diff --git a/applications/metadata.js b/applications/metadata.js
index 3252569..255067b 100644
--- a/applications/metadata.js
+++ b/applications/metadata.js
@@ -52,6 +52,15 @@ var metadata = { "applications": [
"jsfile": "vr-grabscale/VRBuildGrabScale.js",
"icon": "vr-grabscale/logo.png",
"caption": "VR SCALE"
+ },
+ {
+ "isActive": true,
+ "directory": "radar",
+ "name": "Radar",
+ "description": "Show where people are and teleport in the domain.",
+ "jsfile": "radar/radar.js",
+ "icon": "radar/assets/radar-i.svg",
+ "caption": "RADAR"
}
]
};
\ No newline at end of file
diff --git a/applications/radar/LICENSE b/applications/radar/LICENSE
new file mode 100644
index 0000000..3dbc148
--- /dev/null
+++ b/applications/radar/LICENSE
@@ -0,0 +1,3 @@
+Distributed under the Apache License, Version 2.0.
+
+See: http://www.apache.org/licenses/LICENSE-2.0.html
\ No newline at end of file
diff --git a/applications/radar/README.md b/applications/radar/README.md
new file mode 100644
index 0000000..c0c0bf9
--- /dev/null
+++ b/applications/radar/README.md
@@ -0,0 +1,10 @@
+# radar.js
+
+Show where people are and teleport in the domain.
+
+Information: http://ctrlaltstudio.com/vircadia/radar
+
+The master source for this app is in the following repository:
+https://github.com/ctrlaltdavid/cas-vircadia-stuff/tree/master/apps/radar
+
+[LICENSE](LICENSE)
diff --git a/applications/radar/assets/radar-a.svg b/applications/radar/assets/radar-a.svg
new file mode 100644
index 0000000..e42f5aa
--- /dev/null
+++ b/applications/radar/assets/radar-a.svg
@@ -0,0 +1,11 @@
+
+
+
diff --git a/applications/radar/assets/radar-circle-overlay.png b/applications/radar/assets/radar-circle-overlay.png
new file mode 100644
index 0000000..aff1c4a
Binary files /dev/null and b/applications/radar/assets/radar-circle-overlay.png differ
diff --git a/applications/radar/assets/radar-i.svg b/applications/radar/assets/radar-i.svg
new file mode 100644
index 0000000..fc3c5d7
--- /dev/null
+++ b/applications/radar/assets/radar-i.svg
@@ -0,0 +1,11 @@
+
+
+
diff --git a/applications/radar/html/fonts/FiraSans-Regular.ttf b/applications/radar/html/fonts/FiraSans-Regular.ttf
new file mode 100644
index 0000000..d9fdc0e
Binary files /dev/null and b/applications/radar/html/fonts/FiraSans-Regular.ttf differ
diff --git a/applications/radar/html/fonts/FiraSans-SemiBold.ttf b/applications/radar/html/fonts/FiraSans-SemiBold.ttf
new file mode 100644
index 0000000..821a43d
Binary files /dev/null and b/applications/radar/html/fonts/FiraSans-SemiBold.ttf differ
diff --git a/applications/radar/html/fonts/Raleway-Regular.ttf b/applications/radar/html/fonts/Raleway-Regular.ttf
new file mode 100644
index 0000000..c6ec2f0
Binary files /dev/null and b/applications/radar/html/fonts/Raleway-Regular.ttf differ
diff --git a/applications/radar/html/fonts/Raleway-SemiBold.ttf b/applications/radar/html/fonts/Raleway-SemiBold.ttf
new file mode 100644
index 0000000..d61efa3
Binary files /dev/null and b/applications/radar/html/fonts/Raleway-SemiBold.ttf differ
diff --git a/applications/radar/html/radar.css b/applications/radar/html/radar.css
new file mode 100644
index 0000000..b274d8f
--- /dev/null
+++ b/applications/radar/html/radar.css
@@ -0,0 +1,474 @@
+/*!
+radar.css
+
+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
+
+Attributions:
+- Raleway font: https://github.com/impallari/Raleway. Copyright (c) 2010, Matt McInerney (matt@pixelspread.com), Copyright (c)
+ 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com), Copyright (c) 2011, Rodrigo Fuenzalida
+ (www.rfuenzalida.com|hello@rfuenzalida.com), with Reserved Font Name < Raleway >. Licensed under the SIL Open Font License,
+ Version 1.1, available at http://scripts.sil.org/OFL.
+- Fira Sans font: https://github.com/mozilla/Fira. Digitized data copyright (c) 2012-2015, The Mozilla
+ Foundation and Telefonica S.A. with Reserved Font Name < Fira >. Licensed under the SIL Open Font License, Version 1.1,
+ available at http://scripts.sil.org/OFL.
+*/
+
+@font-face {
+ font-family: Raleway-Regular;
+ src: url(./fonts/Raleway-Regular.ttf)
+}
+
+@font-face {
+ font-family: Raleway-SemiBold;
+ src: url(./fonts/Raleway-SemiBold.ttf)
+}
+
+@font-face {
+ font-family: FiraSans-Regular;
+ src: url(./fonts/FiraSans-Regular.ttf)
+}
+
+@font-face {
+ font-family: FiraSans-SemiBold;
+ src: url(./fonts/FiraSans-SemiBold.ttf)
+}
+
+* {
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+ user-select: none;
+}
+
+html {
+ width: 100%;
+ height: 100%;
+ color: #e3e3e3;
+ background-color: #404040;
+}
+
+body {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+h1 {
+ position: relative;
+ top: 9px;
+ font-family: "Raleway-Regular";
+ font-weight: normal;
+ font-size: 18px;
+ color: #ffffff;
+ text-shadow: 1px 1px #252525;
+}
+
+ h1 span {
+ font-size: 14px;
+ }
+
+hr {
+ width: 100%;
+ height: 2px;
+ border: none;
+ border-top: 1px solid #252525;
+ border-bottom: 1px solid #575757;
+}
+
+header {
+ position: relative;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 40px;
+ padding: 0 20px 0 20px;
+}
+
+ header div {
+ position: absolute;
+ right: 20px;
+ top: 8px;
+ font-size: 17px;
+ font-weight: bold;
+ }
+
+ header div span {
+ padding-left: 10px;
+ }
+
+ header div span:hover {
+ color: #00b4ef;
+ }
+
+ header div #help-button {
+ font-size: 21px;
+ position: relative;
+ top: 1px;
+ }
+
+header hr {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+}
+
+section {
+ margin: 40px 20px 40px 20px;
+}
+
+#radar-circle {
+ position: relative;
+ width: 420px;
+ height: 420px;
+ margin: 0 auto 0 auto;
+ border-radius: 210px;
+ background-color: rgb(48, 48, 48);
+}
+
+#radar-circle-display {
+ width: 100%;
+ height: 100%;
+}
+
+#radar-circle-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 214px;
+ border: 1px solid #575757;
+ background: radial-gradient(rgba(48, 48, 48, 0.0) 0%, rgba(48, 48, 48, 0.0) 60%, rgba(87, 87, 87, 1.0) 80%);
+ pointer-events: none;
+ clip-path: circle(210px at center);
+}
+
+ #radar-circle-overlay img {
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ }
+
+.dot {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ margin-left: -4px;
+ margin-top: -4px;
+ border-radius: 4px;
+ padding: 0;
+ z-index: 20;
+ text-align: center;
+}
+
+ .dot:hover {
+ width: 12px;
+ height: 12px;
+ margin-top: -6px;
+ margin-left: -6px;
+ border-radius: 6px;
+ padding: 2px;
+ z-index: 30;
+ }
+
+ .dot.highlighted {
+ width: 12px;
+ height: 12px;
+ margin-top: -6px;
+ margin-left: -6px;
+ border-radius: 6px;
+ border: 2px solid #00ff00;
+ padding: 2px;
+ }
+
+ .dot.labeled {
+ z-index: 30 !important;
+ }
+
+.my-dot {
+ background-color: #00ff00 !important;
+ z-index: 10;
+}
+
+#avatar-label {
+ position: absolute;
+ height: 14px;
+ padding: 0 3px 0 3px;
+ border-radius: 3px;
+ margin-top: -16px;
+ color: #ffffff;
+ background-color: rgba(48, 48, 48, 0.35);
+ font-family: "Raleway-Regular";
+ font-weight: normal;
+ font-size: 14px;
+ line-height: 13px;
+ text-align: center;
+ white-space: nowrap;
+ z-index: 40;
+ display: none;
+}
+
+#teleport-circle {
+ position: absolute;
+ width: 82px;
+ height: 82px;
+ border-radius: 41px;
+ background-color: rgba(0, 255, 0, 0.2);
+ display: none;
+}
+
+#avatar-count {
+ position: absolute;
+ right: 0;
+ top: 0;
+ font-family: "Raleway-Regular";
+ font-weight: normal;
+ font-size: 15px;
+}
+
+ #avatar-count span {
+ display: inline-block;
+ min-width: 15px;
+ text-align: right;
+ font-family: "FiraSans-Regular";
+ font-size: 15px;
+ }
+
+#radar-scale {
+ width: 420px;
+ margin: 10px auto 10px auto;
+}
+
+ #radar-scale td {
+ width: 33%;
+ text-align: center;
+ font-family: "FiraSans-Regular";
+ font-weight: normal;
+ font-size: 15px;
+ }
+
+ #radar-scale td:first-child {
+ text-align: left;
+ }
+
+ #radar-scale td:last-child {
+ text-align: right;
+ }
+
+footer {
+ position: relative;
+ width: 100%;
+ padding: 40px 20px 0 20px;
+}
+
+ footer hr {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+
+
+#scale {
+ width: 100%;
+}
+
+ #scale thead td {
+ text-align: left;
+ padding-bottom: 5px;
+ font-family: "Raleway-SemiBold";
+ font-weight: normal;
+ font-size: 14px;
+ }
+
+ #scale tbody td {
+ display: block;
+ float: left;
+ width: 7.6%;
+ text-align: center;
+ font-family: "FiraSans-Regular";
+ font-weight: normal;
+ font-size: 15px;
+ }
+
+ #scale input[type=radio]:checked + label::before {
+ line-height: 11px;
+ }
+
+.units {
+ font-family: "Raleway-Regular";
+ font-weight: normal;
+ font-size: 13px;
+}
+
+input[type=radio] {
+ display: none;
+}
+
+ input[type=radio] + label::before {
+ content: "";
+ width: 12px;
+ height: 12px;
+ border-radius: 6px;
+ padding: 0;
+ background-image: linear-gradient(#7D7D7D, #6B6A6B);
+ display: inline-block;
+ margin: 0 10px 0 10px;
+ line-height: 11px;
+ position: relative;
+ top: 1px;
+ }
+
+ input[type=radio]:checked + label::before {
+ font-size: 18px;
+ content: "\25cf";
+ color: #00b4ef;
+ line-height: 9px;
+ position: relative;
+ top: 0;
+ }
+
+ input[type=radio] + label:hover::before {
+ background-image: linear-gradient(#ffffff, #afafaf);
+ }
+
+
+form.dialog {
+ position: absolute;
+ top: 40px;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: 20px;
+ padding: 20px;
+ z-index: 100;
+ border-radius: 15px;
+ border: 1px solid #676767;
+ box-shadow: 1px 1px #252525;
+ background-color: #505050;
+ font-family: "Raleway-Regular";
+ font-weight: normal;
+ font-size: 14px;
+ color: #f3f3f3;
+ visibility: hidden;
+}
+
+ form.dialog.visible {
+ visibility: visible;
+ }
+
+h2 {
+ font-family: "Raleway-Regular";
+ font-weight: normal;
+ font-size: 16px;
+ color: #ffffff;
+ text-shadow: 1px 1px #252525;
+}
+
+#version {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ font-family: "FiraSans-Regular";
+ font-weight: normal;
+ font-size: 15px;
+ font-style: italic;
+ color: #e3e3e3;
+}
+
+p {
+ margin-top: 15px;
+}
+
+ul {
+ margin-left: 20px;
+}
+
+li {
+ margin-top: 3px;
+}
+
+ li li {
+ margin-top: 1px;
+ }
+
+a {
+ color: #00c0ff;
+ text-decoration: none;
+}
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+.radio {
+ margin-left: 0;
+}
+
+ .radio li {
+ list-style-type: none;
+ margin-top: 5px;
+ }
+
+.ok {
+ position: absolute;
+ bottom: 20px;
+ right: 20px;
+}
+
+ .ok input {
+ width: 100px;
+ height: 30px;
+ border: none;
+ border-radius: 7.5px;
+ font-size: 13px;
+ font-weight: bold;
+ color: #f8f8f8;
+ }
+
+ .ok input {
+ background: linear-gradient(#00b4ef, #1080b8);
+ }
+
+ .ok input:focus {
+ outline: none;
+ }
+
+ .ok input:hover {
+ background: linear-gradient(#00b4ef, #00b4ef);
+ }
+
+#help-content {
+ margin-top: 15px;
+ padding-right: 4px;
+ height: 502px;
+ overflow-y: auto;
+}
+
+ #help-content p:first-child {
+ margin-top: 0;
+ }
+
+::-webkit-scrollbar {
+ width: 18px;
+ height: 18px;
+}
+
+::-webkit-scrollbar-button {
+ display: none;
+}
+
+::-webkit-scrollbar-track {
+ border-radius: 9px;
+ background: #484848;
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: 9px;
+ background: #707070;
+}
diff --git a/applications/radar/html/radar.html b/applications/radar/html/radar.html
new file mode 100644
index 0000000..42d20fd
--- /dev/null
+++ b/applications/radar/html/radar.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+ Radar
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
Avatars: 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/applications/radar/html/radar.js b/applications/radar/html/radar.js
new file mode 100644
index 0000000..18caca0
--- /dev/null
+++ b/applications/radar/html/radar.js
@@ -0,0 +1,1275 @@
+/*!
+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
+
+}());
diff --git a/applications/radar/radar.js b/applications/radar/radar.js
new file mode 100644
index 0000000..bf2d192
--- /dev/null
+++ b/applications/radar/radar.js
@@ -0,0 +1,844 @@
+/*!
+radar.js
+
+Created by David Rowe on 16 Nov 2017.
+Copyright 2017-2020 David Rowe.
+
+Information: http://ctrlaltstudio.com/vircadia/radar
+
+Disclaimers:
+1. The user identification provided by this app is not guaranteed: users can set their display name to whatever they like.
+2. The content of the display names displayed by this app is not moderated by this app.
+
+Distributed under the Apache License, Version 2.0.
+See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
+*/
+
+/* global AvatarManager, Camera, HMD, MyAvatar, Overlays, Quat, Settings, Vec3, Window, location */
+
+(function () {
+
+ "use strict";
+
+ var APP_NAME = "Radar",
+ APP_VERSION = "2.3.0", // Version number also needs to be set in web page HTML and JavaScript files.
+
+ SIMULATE = false,
+ INSTRUMENT = false,
+
+ // 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.
+ Preferences,
+ Communications,
+ Updates,
+ App,
+
+ // Global objects.
+ tablet = null,
+
+ INFO = "INFO:",
+ //WARN = "WARN:",
+ ERROR = "ERROR:",
+ ERROR_MISSING_CASE = "Missing case:";
+
+ if (SIMULATE) {
+ Script.include("simulate.js");
+ }
+
+ function log() {
+ var i, length, strings = [];
+
+ for (i = 0, length = arguments.length; i < length; i++) {
+ strings.push(arguments[i]);
+ }
+
+ print("[CtrlAltStudio radar.js] " + strings.join(" "));
+ }
+
+ //#region Preferences ======================================================================================================
+
+ Preferences = (function () {
+ // Manages the application preferences - "controls" and "settings" in the dialog.
+
+ var PREFERENCES_ROOT = "com.ctrlaltstudio.radar.",
+ RADAR_RANGE_SETTING = PREFERENCES_ROOT + "range", // Must be different name to variable for obfuscation.
+ radarRange = RADAR_RANGE_DEFAULT,
+ SHOW_OWN_SETTING = PREFERENCES_ROOT + "ownAvatar", // Must be different name to variable for obfuscation.
+ SHOW_OWN_NEVER = 0,
+ SHOW_OWN_THIRD = 1,
+ SHOW_OWN_ALWAYS = 2,
+ SHOW_OWN_DEFAULT = SHOW_OWN_THIRD,
+ showOwn = SHOW_OWN_DEFAULT,
+ REFRESH_RATE_SETTING = PREFERENCES_ROOT + "updateRate", // Must be different name to variable for obfuscation.
+ REFRESH_RATE_FASTEST = 4,
+ REFRESH_RATE_FASTER = 3,
+ REFRESH_RATE_MEDIUM = 2,
+ REFRESH_RATE_SLOWER = 1,
+ REFRESH_RATE_SLOWEST = 0,
+ REFRESH_RATE_DEFAULT = REFRESH_RATE_MEDIUM,
+ refreshRate = REFRESH_RATE_DEFAULT,
+ preferencesChangedCallback = null;
+
+ function getPreferences() {
+ return {
+ radarRange: radarRange,
+ showOwn: showOwn,
+ refreshRate: refreshRate
+ };
+ }
+
+ function getRadarRange() {
+ return radarRange;
+ }
+
+ function setRadarRange(range) {
+ radarRange = range;
+ Settings.setValue(RADAR_RANGE_SETTING, radarRange);
+ preferencesChangedCallback(getPreferences());
+ }
+
+ function getShowOwn() {
+ return showOwn;
+ }
+
+ function setShowOwn(show) {
+ showOwn = show;
+ Settings.setValue(SHOW_OWN_SETTING, showOwn);
+ preferencesChangedCallback(getPreferences());
+ }
+
+ function getRefreshRate() {
+ return refreshRate;
+ }
+
+ function setRefreshRate(rate) {
+ refreshRate = rate;
+ Settings.setValue(REFRESH_RATE_SETTING, refreshRate);
+ preferencesChangedCallback(getPreferences());
+ }
+
+ function setPreferencesChangedCallback(callback) {
+ preferencesChangedCallback = callback;
+ }
+
+ function setUp() {
+ radarRange = Settings.getValue(RADAR_RANGE_SETTING, RADAR_RANGE_DEFAULT);
+ showOwn = Settings.getValue(SHOW_OWN_SETTING, SHOW_OWN_DEFAULT);
+ refreshRate = Settings.getValue(REFRESH_RATE_SETTING, REFRESH_RATE_DEFAULT);
+ }
+
+ function tearDown() {
+ // Nothing to do.
+ }
+
+ return {
+ getRadarRange: getRadarRange,
+ setRadarRange: setRadarRange,
+ SHOW_OWN_NEVER: SHOW_OWN_NEVER,
+ SHOW_OWN_THIRD: SHOW_OWN_THIRD,
+ SHOW_OWN_ALWAYS: SHOW_OWN_ALWAYS,
+ getShowOwn: getShowOwn,
+ setShowOwn: setShowOwn,
+ REFRESH_RATE_SLOWEST: REFRESH_RATE_SLOWEST,
+ REFRESH_RATE_SLOWER: REFRESH_RATE_SLOWER,
+ REFRESH_RATE_MEDIUM: REFRESH_RATE_MEDIUM,
+ REFRESH_RATE_FASTER: REFRESH_RATE_FASTER,
+ REFRESH_RATE_FASTEST: REFRESH_RATE_FASTEST,
+ getRefreshRate: getRefreshRate,
+ setRefreshRate: setRefreshRate,
+ getPreferences: getPreferences,
+ preferencesChanged: {
+ connect: setPreferencesChangedCallback
+ },
+ setUp: setUp,
+ tearDown: tearDown
+ };
+
+ }());
+
+ //#endregion
+
+ //#region Communications ===================================================================================================
+
+ Communications = (function () {
+ // Manages the communications with the Web page script.
+
+ var
+ isVersionsChecked = false,
+ readyCallback = null;
+
+ function onWebEventReceived(data) {
+ var message,
+ avatarPosition,
+ cameraVector,
+ ERROR_FILE_VERSIONS_DONT_MATCH = APP_NAME + " script file version numbers don't match",
+ ERROR_PLEASE_RELOAD_SCRIPT = "Please reload " + APP_NAME + " script.";
+
+ try {
+ message = JSON.parse(data);
+ } catch (e) {
+ return;
+ }
+
+ if (message.id !== SCRIPT_ID) {
+ return;
+ }
+
+ switch (message.type) {
+ case READY_MESSAGE:
+ if (readyCallback) {
+ readyCallback();
+ }
+ tablet.emitScriptEvent(JSON.stringify({
+ id: SCRIPT_ID,
+ type: READY_MESSAGE,
+ hmd: HMD.active
+ }));
+ break;
+ case GET_CONTROLS_MESSAGE:
+ tablet.emitScriptEvent(JSON.stringify({
+ id: SCRIPT_ID,
+ type: SET_CONTROLS_MESSAGE,
+ controls: {
+ range: Preferences.getRadarRange()
+ }
+ }));
+ break;
+ case SET_CONTROLS_MESSAGE:
+ Preferences.setRadarRange(message.controls.range);
+ break;
+ case GET_SETTINGS_MESSAGE:
+ tablet.emitScriptEvent(JSON.stringify({
+ id: SCRIPT_ID,
+ type: SET_SETTINGS_MESSAGE,
+ settings: {
+ showOwn: Preferences.getShowOwn(),
+ refreshRate: Preferences.getRefreshRate()
+ }
+ }));
+ break;
+ case SET_SETTINGS_MESSAGE:
+ Preferences.setShowOwn(message.settings.showOwn);
+ Preferences.setRefreshRate(message.settings.refreshRate);
+ break;
+ case AVATARS_MESSAGE:
+ Updates.avatarsDisplayed();
+ break;
+ case TELEPORT_MESSAGE:
+ avatarPosition = MyAvatar.position;
+ cameraVector = Vec3.subtract(Camera.position, avatarPosition);
+ if (message.vector.y === null) {
+ message.vector.y = -cameraVector.y;
+ }
+ MyAvatar.goToLocation(Vec3.sum(Vec3.sum(avatarPosition, message.vector), cameraVector),
+ false, undefined, message.isAvatar, true);
+ break;
+ case OPEN_URL_MESSAGE:
+ Window.openUrl(message.url);
+ break;
+ case VERSIONS_MESSAGE:
+ if (!isVersionsChecked) {
+ if (message.scriptVersion !== APP_VERSION || message.htmlVersion !== APP_VERSION) {
+ log(ERROR, ERROR_FILE_VERSIONS_DONT_MATCH + ": " + APP_VERSION + ", " + message.scriptVersion
+ + " (script), " + message.htmlVersion + " (HTML)");
+ Window.alert(ERROR_FILE_VERSIONS_DONT_MATCH + "!\n" + ERROR_PLEASE_RELOAD_SCRIPT);
+ }
+ isVersionsChecked = true;
+ }
+ break;
+ case LOG_MESSAGE:
+ log(message.message);
+ break;
+ default:
+ log(ERROR, ERROR_MISSING_CASE, 0, data);
+ }
+ }
+
+ function sendAvatarData(radarRange, avatarData) {
+ tablet.emitScriptEvent(JSON.stringify({
+ id: SCRIPT_ID,
+ type: AVATARS_MESSAGE,
+ range: radarRange,
+ data: avatarData
+ }));
+ }
+
+ function clearAvatarData() {
+ tablet.emitScriptEvent(JSON.stringify({
+ id: SCRIPT_ID,
+ type: CLEAR_MESSAGE
+ }));
+ }
+
+ function sendRotation(rotation) {
+ tablet.emitScriptEvent(JSON.stringify({
+ id: SCRIPT_ID,
+ type: ROTATION_MESSAGE,
+ rotation: rotation
+ }));
+ }
+
+ function connectReadyCallback(callback) {
+ readyCallback = callback;
+ }
+
+ function disconnectReadyCallback(callback) {
+ if (readyCallback === callback) {
+ readyCallback = null;
+ }
+ }
+
+ return {
+ onWebEventReceived: onWebEventReceived,
+ ready: {
+ connect: connectReadyCallback,
+ disconnect: disconnectReadyCallback
+ },
+ sendAvatarData: sendAvatarData,
+ clearAvatarData: clearAvatarData,
+ sendRotation: sendRotation
+ };
+
+ }());
+
+ //#endregion
+
+ //#region Updates ==========================================================================================================
+
+ Updates = (function () {
+ // Main update loop.
+
+ var radarRange,
+ RADAR_SEARCH_MULTIPLIER = Math.sqrt(2), // Encompass search cylinder.
+ radarSearchRange,
+ cameraMode,
+ showOwn,
+ isShowOwn,
+ FIRST_PERSON_CAMERA_MODES = ["first person", "first person look at"],
+ refreshRate,
+
+ ROTATION_INTERVALS = [
+ 200, // Slow
+ 150,
+ 100, // Medium
+ 50,
+ 25 // Fast
+ ],
+ rotationInterval = ROTATION_INTERVALS[Preferences.REFRESH_RATE_MEDIUM],
+ rotationTimer = null,
+
+ AVATAR_INTERVALS = [
+ 2000, // Slow
+ 1000,
+ 750, // Medium
+ 350,
+ 100 // Fast
+ ],
+ avatarsInterval = AVATAR_INTERVALS[Preferences.REFRESH_RATE_MEDIUM],
+ avatarsUpdateTimer = null,
+ timeToPrepareDataStart,
+ timeToPrepareData = 0,
+ timeToDisplayDataStart,
+ timeToDisplayData = 0,
+ targetSendTime = 0,
+ avatarsSendTimer = null,
+ isDisplayingData = false,
+
+ MAX_DISPLAY_NAME_LENGTH = 30,
+
+ mySessionUUID,
+
+ isRunning = false;
+
+ function calculateIsShowOwn() {
+ isShowOwn = showOwn === Preferences.SHOW_OWN_ALWAYS
+ || showOwn === Preferences.SHOW_OWN_THIRD && FIRST_PERSON_CAMERA_MODES.indexOf(cameraMode) === -1;
+ }
+
+ function calculateIntervals() {
+ rotationInterval = ROTATION_INTERVALS[refreshRate];
+ avatarsInterval = AVATAR_INTERVALS[refreshRate];
+ }
+
+ function setCameraMode(mode) {
+ cameraMode = mode;
+ calculateIsShowOwn();
+ }
+
+ function setPreferences(preferences) {
+ showOwn = preferences.showOwn;
+ calculateIsShowOwn();
+ radarRange = preferences.radarRange;
+ radarSearchRange = RADAR_SEARCH_MULTIPLIER * radarRange;
+ refreshRate = preferences.refreshRate;
+ calculateIntervals();
+ }
+
+ function updateRotation() {
+ var tabletOrientation,
+ tabletDirection,
+ tabletHorizontalDirection,
+ rotation;
+
+ if (App.isHMDMode()) {
+ tabletOrientation = Overlays.getProperty(HMD.tabletID, "orientation");
+ tabletDirection = Quat.getUp(tabletOrientation); // Out the top of the tablet.
+ if (Vec3.dot(tabletDirection, Vec3.UNIT_Y) > 0.5) {
+ tabletDirection = Vec3.multiply(-1, Quat.getForward(tabletOrientation)); // Out the back of the tablet.
+ }
+ tabletHorizontalDirection = Vec3.cross(tabletDirection, Vec3.UNIT_Y);
+
+ rotation = Vec3.orientedAngle(Vec3.UNIT_X, tabletHorizontalDirection, Vec3.UNIT_Y);
+ } else {
+ rotation = Quat.safeEulerAngles(Camera.orientation).y;
+ }
+
+ Communications.sendRotation(rotation);
+
+ rotationTimer = Script.setTimeout(updateRotation, rotationInterval);
+ }
+
+ /*
+ // Code for displaying avatar dots at positions and elevations for screen snap or testing elevation colours.
+ var sessionIDs = [Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(),
+ Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate(), Uuid.generate()];
+
+ function updateAvatars() {
+ var avatarPositions,
+ avatarNames,
+ avatarDataRange = 10.0,
+ avatarData = [],
+ i;
+
+ avatarPositions = [
+ { x: 0, y: 0, z: 0 },
+ { x: -2.5, y: 0, z: -1 },
+ { x: -2.8, y: 0, z: -1.3 },
+ { x: -3, y: 0, z: -7 },
+ { x: -4, y: 0, z: -7.7 },
+ { x: -4.3, y: 0, z: -7.3 },
+ { x: -4.3, y: 0, z: -7.8 },
+ { x: -3.24, y: 0, z: -8.4 },
+ { x: 1, y: -7, z: 6 },
+ { x: 5, y: 8, z: -3 }
+ ];
+ avatarNames = ["ctrlaltdavid", "", "", "", "", "", "", "", "", "wade", "", ""];
+
+ for (i = 0; i < avatarPositions.length; i += 1) {
+ avatarData.push({
+ uuid: sessionIDs[i],
+ vector: avatarPositions[i],
+ name: avatarNames[i]
+ });
+ }
+ avatarData[0].isMyAvatar = true;
+
+ Communications.sendAvatarData(avatarDataRange, avatarData);
+ avatarsUpdateTimer = Script.setTimeout(updateAvatars, avatarsInterval);
+ }
+ */
+
+ function limitDisplayName(name) {
+ // Limit display name here so that extraneous data is not sent in message to HTML code.
+ if (name.length > MAX_DISPLAY_NAME_LENGTH) {
+ return name.slice(0, MAX_DISPLAY_NAME_LENGTH) + "…";
+ }
+ return name;
+ }
+
+ function updateAvatars() {
+ // Finds avatars in cylinder, not sphere.
+ // Avatar data update loop:
+ // - The desired update loop time is avatarsInterval.
+ // - The update loop time is extended if the HTML script takes longer to display the data.
+ // - The update loop comprises:
+ // - Preparing the avatar data.
+ // - Sending the avatar data at the target interval or as soon as the previous data has been displayed.
+ // - Scheduling preparing the next avatar data so that it is ready at the anticipated send time.
+ // - Displaying the avatar data in the HTML script. (Includes teleport elevation searching.)
+
+ var cameraPosition,
+ avatarIDs,
+ myAvatarIndex,
+ palData,
+ vector,
+ sessionUUID,
+ avatarDatum,
+ avatarData = [],
+ avatarDataRange, // The radar range that avatarData is for.
+ i, length,
+ MINIMUM_UPDATE_DELAY = 2,
+ sendDelay,
+ MINIMUM_SEND_DELAY = 2,
+ SEND_RETRY_INTERVAL = 25;
+
+ timeToPrepareDataStart = Date.now();
+
+ // Timer has fired.
+ avatarsUpdateTimer = null;
+
+ // Get avatar data.
+ cameraPosition = Camera.position;
+ avatarIDs = AvatarManager.getAvatarsInRange(cameraPosition, radarSearchRange);
+ avatarDataRange = radarRange;
+
+ // Remove own avatar if necessary.
+ myAvatarIndex = avatarIDs.indexOf(mySessionUUID);
+ if (myAvatarIndex !== -1 && !isShowOwn) {
+ avatarIDs.splice(myAvatarIndex, 1);
+ }
+
+ // Collect avatar data.
+ palData = AvatarManager.getPalData(avatarIDs)["data"]; // Property name as string to avoid obfuscation.
+ for (i = 0, length = palData.length; i < length; i++) {
+ // If session display name is undefined then the data is messed up (e.g., spheres problem).
+ // The pal.js script also ignores items with empty name fields.
+ if (!palData[i].sessionDisplayName) {
+ continue;
+ }
+
+ sessionUUID = palData[i].sessionUUID;
+
+ // FIXME: AvatarManager.getPalData() returns with sessionUUID === "" for own avatar.
+ // Manuscript case 19693.
+ if (sessionUUID === "") {
+ if (isShowOwn) {
+ sessionUUID = mySessionUUID;
+ } else {
+ continue;
+ }
+ }
+
+ vector = Vec3.subtract(palData[i].position, cameraPosition);
+ if (Math.abs(vector.y) <= radarRange) {
+ if (Vec3.length({ x: vector.x, y: 0, z: vector.z }) <= radarRange) {
+ avatarDatum = {
+ uuid: sessionUUID,
+ vector: vector,
+ name: limitDisplayName(palData[i].sessionDisplayName)
+ };
+ if (sessionUUID === mySessionUUID) {
+ // Don't set value for each avatar so as to reduce EventBridge message size.
+ avatarDatum.isMyAvatar = true;
+ }
+ avatarData.push(avatarDatum);
+ }
+ }
+ }
+
+ timeToPrepareData = Date.now() - timeToPrepareDataStart;
+ if (INSTRUMENT) {
+ log("Main script : prepare : " + timeToPrepareData);
+ // 2019 01 07
+ // Simulation:
+ // - 100 avatars: 3ms
+ // - 1000 avatars: 48ms
+ }
+
+ // Send avatar data at target time, if can.
+ sendDelay = targetSendTime - Date.now(); // Should hover around 0 for a reasonably loaded radar.
+ if (INSTRUMENT) {
+ log("Main script : delay : " + sendDelay);
+ }
+
+ // Schedule preparing next data set so that it is ready to send at later of target interval or display update from
+ // previous data set.
+ avatarsUpdateTimer = Script.setTimeout(updateAvatars,
+ Math.max(Math.max(avatarsInterval, timeToDisplayData) - timeToPrepareData + sendDelay, MINIMUM_UPDATE_DELAY));
+
+ function sendData() {
+ var instrumentTimestamp;
+
+ if (!isRunning) {
+ return;
+ }
+
+ // Delay sending data until after previous lot has been processed.
+ if (isDisplayingData) {
+ if (INSTRUMENT) {
+ log("Main script : reschedule send");
+ }
+ avatarsSendTimer = Script.setTimeout(sendData, SEND_RETRY_INTERVAL);
+ return;
+ }
+ avatarsSendTimer = null;
+
+ if (INSTRUMENT) {
+ instrumentTimestamp = Date.now();
+ }
+
+ // Set target for sending next lot of data.
+ targetSendTime = Date.now() + avatarsInterval;
+
+ // Send current lost of data.
+ isDisplayingData = true;
+ timeToDisplayDataStart = Date.now();
+ Communications.sendAvatarData(avatarDataRange, avatarData);
+
+ if (INSTRUMENT) {
+ log("Main script : send : " + (Date.now() - instrumentTimestamp));
+ // 2019 01 07
+ // Simulation:
+ // - 100 avatars: 1ms
+ // - 1000 avatars: 5ms
+ }
+ }
+
+ avatarsSendTimer = Script.setTimeout(sendData,
+ Math.max(sendDelay, MINIMUM_SEND_DELAY)); // Caters for negative sendDelay values.
+ }
+
+ function onSessionUUIDChanged() {
+ mySessionUUID = MyAvatar.sessionUUID;
+ }
+
+ function start() {
+ isRunning = true;
+ mySessionUUID = MyAvatar.sessionUUID;
+ MyAvatar.sessionUUIDChanged.connect(onSessionUUIDChanged);
+ if (avatarsUpdateTimer === null) {
+ targetSendTime = Date.now() + avatarsInterval;
+ avatarsUpdateTimer = Script.setTimeout(updateAvatars, avatarsInterval);
+ }
+ if (rotationTimer === null) {
+ rotationTimer = Script.setTimeout(updateRotation, rotationInterval);
+ }
+ }
+
+ function avatarsDisplayed() {
+ isDisplayingData = false;
+ timeToDisplayData = Date.now() - timeToDisplayDataStart;
+
+ if (INSTRUMENT) {
+ log("Main script : display : " + timeToDisplayData);
+ // 2019 01 07
+ // Simulation:
+ // - 100 avatars: 16ms
+ // - 1000 avatars: 25ms
+ }
+ }
+
+ function stop() {
+ isRunning = false;
+ isDisplayingData = false;
+ if (avatarsSendTimer !== null) {
+ Script.clearTimeout(avatarsSendTimer);
+ avatarsSendTimer = null;
+ }
+ if (avatarsUpdateTimer !== null) {
+ Script.clearTimeout(avatarsUpdateTimer);
+ avatarsUpdateTimer = null;
+ }
+ if (rotationTimer !== null) {
+ Script.clearTimeout(rotationTimer);
+ rotationTimer = null;
+ }
+ MyAvatar.sessionUUIDChanged.disconnect(onSessionUUIDChanged);
+ }
+
+ function setUp() {
+ setPreferences(Preferences.getPreferences());
+ Preferences.preferencesChanged.connect(setPreferences);
+ }
+
+ function tearDown() {
+ // Nothing to do.
+ }
+
+ return {
+ setPreferences: setPreferences,
+ setCameraMode: setCameraMode,
+ start: start,
+ avatarsDisplayed: avatarsDisplayed,
+ stop: stop,
+ setUp: setUp,
+ tearDown: tearDown
+ };
+
+ }());
+
+ //#endregion
+
+ //#region App ==============================================================================================================
+
+ App = (function () {
+ // Manages the interactions with the Interface app environment.
+
+ var APP_ICON_ACTIVE = Script.resolvePath("./assets/radar-a.svg"),
+ APP_ICON_INACTIVE = Script.resolvePath("./assets/radar-i.svg"),
+ APP_HTML_PAGE = Script.resolvePath("./html/radar.html"),
+ APP_BUTTON_TEXT = "RADAR",
+
+ button = null,
+ isAppActive = false,
+
+ HMD_TABLET_BECOMES_TOOLBAR_SETTING = "hmdTabletBecomesToolbar",
+ isHMDTabletBecomesToolbar = false,
+ isHMDActive = false;
+
+
+ function onDisplayModeChanged(isHMDMode) {
+ // Close app if have switched between desktop and HMD modes, else tablet can't be used or toolbar button stays on.
+ if (isAppActive && isHMDMode !== isHMDActive) {
+ isHMDTabletBecomesToolbar = Settings.getValue(HMD_TABLET_BECOMES_TOOLBAR_SETTING, false);
+ isHMDActive = isHMDMode;
+ tablet.gotoHomeScreen();
+ }
+ }
+
+ function onPossibleDomainChangeRequired() {
+ // Clear radar display so that out-of-date display doesn't unexpectedly jump before updating to new location.
+ Communications.clearAvatarData();
+ }
+
+ function startApp() {
+
+ isHMDTabletBecomesToolbar = Settings.getValue(HMD_TABLET_BECOMES_TOOLBAR_SETTING, false);
+ isHMDActive = HMD.active;
+
+ HMD.displayModeChanged.connect(onDisplayModeChanged);
+ location.possibleDomainChangeRequired.connect(onPossibleDomainChangeRequired);
+ location.possibleDomainChangeRequiredViaICEForID.connect(onPossibleDomainChangeRequired);
+
+ Camera.modeUpdated.connect(Updates.setCameraMode);
+ Updates.setCameraMode(Camera.mode);
+
+ Communications.ready.connect(Updates.start);
+ tablet.webEventReceived.connect(Communications.onWebEventReceived);
+ }
+
+ function stopApp() {
+ Updates.stop();
+
+ tablet.webEventReceived.disconnect(Communications.onWebEventReceived);
+ Communications.ready.disconnect(Updates.start);
+
+ Camera.modeUpdated.disconnect(Updates.setCameraMode);
+
+ location.possibleDomainChangeRequiredViaICEForID.disconnect(onPossibleDomainChangeRequired);
+ location.possibleDomainChangeRequired.disconnect(onPossibleDomainChangeRequired);
+ HMD.displayModeChanged.disconnect(onDisplayModeChanged);
+ }
+
+ function onButtonClicked() {
+ if (!isAppActive) {
+ tablet.gotoWebScreen(APP_HTML_PAGE);
+ } else {
+ tablet.gotoHomeScreen();
+ }
+ }
+
+ function onTabletScreenChanged(type, url) {
+ var active;
+
+ active = url.slice(0, APP_HTML_PAGE.length) === APP_HTML_PAGE;
+ if (active === isAppActive) {
+ return;
+ }
+
+ isAppActive = active;
+ button.editProperties({ isActive: isAppActive });
+ if (isAppActive) {
+ startApp();
+ } else {
+ stopApp();
+ }
+ }
+
+ function isHMDMode() {
+ return isHMDActive && !isHMDTabletBecomesToolbar;
+ }
+
+ function setUp() {
+ tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
+ if (!tablet) {
+ log(ERROR, "Tablet not found!");
+ return;
+ }
+
+ button = tablet.addButton({
+ icon: APP_ICON_INACTIVE,
+ activeIcon: APP_ICON_ACTIVE,
+ text: APP_BUTTON_TEXT,
+ isActive: false
+ });
+
+ if (!button) {
+ log(ERROR, "Tablet button not created!");
+ tablet = null;
+ return;
+ }
+
+ tablet.screenChanged.connect(onTabletScreenChanged);
+ button.clicked.connect(onButtonClicked);
+ }
+
+ function tearDown() {
+ if (isAppActive) {
+ stopApp();
+ tablet.gotoHomeScreen(); // Close desktop window.
+ }
+
+ if (button) {
+ button.clicked.disconnect(onButtonClicked);
+ tablet.removeButton(button);
+ button = null;
+ }
+
+ if (tablet) {
+ tablet.screenChanged.disconnect(onTabletScreenChanged);
+ tablet = null;
+ }
+ }
+
+ return {
+ isHMDMode: isHMDMode,
+ setUp: setUp,
+ tearDown: tearDown
+ };
+
+ }());
+
+ //#endregion
+
+ //#region Set up and tear down =============================================================================================
+
+ function setUp() {
+ log(INFO, APP_NAME, APP_VERSION);
+ App.setUp();
+ Preferences.setUp();
+ Updates.setUp();
+ }
+
+ function tearDown() {
+ Script.scriptEnding.disconnect(tearDown);
+ App.tearDown();
+ Preferences.tearDown();
+ Updates.tearDown();
+ }
+
+ setUp();
+ Script.scriptEnding.connect(tearDown);
+
+ //#endregion
+
+}());