From ba16106c05449462b290f08683f29780367233de Mon Sep 17 00:00:00 2001
From: jahugg <jan@huggenberg.ch>
Date: Tue, 28 Feb 2023 18:14:56 +0100
Subject: [PATCH] first working motion prototype

---
 src/main.js            | 167 ++++++++++++++++++++++++++++++++++++-----
 src/modules/Vector.js  |  60 +++++++++++++++
 src/modules/helpers.js |   5 ++
 src/styles/config.css  |  12 ++-
 src/styles/main.css    |  16 +++-
 src/styles/main.less   |  17 ++++-
 6 files changed, 252 insertions(+), 25 deletions(-)
 create mode 100644 src/modules/Vector.js

diff --git a/src/main.js b/src/main.js
index 917fdae..efb886b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,10 +1,12 @@
 import * as helpers from "./modules/helpers.js";
+import { Point, Vector } from "./modules/Vector.js";
 
 const config = {
   controls: {
     mouse: true,
     button: true,
-    voice: true,
+    voice: false,
+    motion: true,
   },
 };
 
@@ -100,6 +102,9 @@ const navigation = {
   },
 };
 
+// Create WebSocket connection.
+const socket = new WebSocket(`wss://192.168.227.52:443`);
+
 // init application
 function init() {
   // build navigation
@@ -129,6 +134,19 @@ function init() {
   if (config.controls.mouse) addMouseControls();
   if (config.controls.button) addButtonControls();
   if (config.controls.voice) addVoiceControls();
+  if (config.controls.motion && "RelativeOrientationSensor" in window)
+    addMotionControls();
+
+  // listen for WebSocket messages
+  socket.addEventListener("message", (event) => {
+    let message = JSON.parse(event.data);
+    console.log(message);
+
+    // perform action without broadcasting message again
+    if (message.type === "select") navigateToPath(message.value, true, false);
+    else if (message.type === "focus")
+      focusNavigationItem(message.value, false);
+  });
 }
 
 // update navigation
@@ -236,7 +254,7 @@ function buildPageContents(object) {
 // ===================
 
 // navigate to path
-function navigateToPath(path, pushState = true) {
+function navigateToPath(path, pushState = true, broadcast = true) {
   // find object with path
   let navObj = helpers.findNestedObject(navigation, "path", path);
   if (!navObj) navObj = navigation.home; // fallback to home
@@ -251,6 +269,10 @@ function navigateToPath(path, pushState = true) {
   // Push state to browser history
   if (pushState)
     history.pushState(stateObj, stateObj.pageTitle, stateObj.pageUrl);
+
+  // broadcast selection event to other clients
+  if (broadcast && socket.readyState === WebSocket.OPEN)
+    socket.send(JSON.stringify({ type: "select", value: path }));
 }
 
 // navigate to currently selected item
@@ -265,7 +287,7 @@ function navigateToSelected() {
 }
 
 // focus specific navigation item
-function focusNavigationItem(index) {
+function focusNavigationItem(index, broadcast = true) {
   // unselect all items
   const navigationList = document.querySelectorAll("#navigation li");
   for (let item of navigationList) delete item.dataset.selected;
@@ -275,6 +297,10 @@ function focusNavigationItem(index) {
     `#navigation > ul > li:nth-child(${index})`
   );
   navigationItem.dataset.selected = "";
+
+  // broadcast focus event to other clients
+  if (broadcast && socket.readyState === WebSocket.OPEN)
+    socket.send(JSON.stringify({ type: "focus", value: index }));
 }
 
 // focus next navigation item
@@ -308,6 +334,30 @@ function focusPreviousNavigationItem() {
   }
 }
 
+// focus closest item to point
+function focusClosestItem(
+  focusPoint = new Point(window.innerWidth / 2, window.innerHeight / 2)
+) {
+  let lastDistance = 10000;
+  let index = 1;
+  let closestIndex = 0;
+  const itemList = document.querySelectorAll("#navigation ul li");
+  for (let item of itemList) {
+    let rect = item.getBoundingClientRect();
+    let itemCenterPoint = new Point(
+      rect.left + rect.width / 2,
+      rect.top + rect.height / 2
+    );
+    let vector = new Vector(focusPoint, itemCenterPoint);
+    if (vector.magnitude <= lastDistance) {
+      closestIndex = index;
+      lastDistance = vector.magnitude;
+    }
+    index++;
+  }
+  focusNavigationItem(closestIndex);
+}
+
 // moving up navigation tree
 function moveUpNavigationLevel() {
   const path = window.location.pathname;
@@ -369,10 +419,6 @@ function addButtonControls() {
   // add two button controls for menu
   document.addEventListener("keydown", onKeyDown);
 
-  window.addEventListener("volumechange", function (event) {
-    printLogMsg("Volume up button was pressed.");
-  });
-
   // on keydown event
   function onKeyDown(event) {
     // prevent defaults
@@ -398,14 +444,6 @@ function addButtonControls() {
     else if (event.code === "VolumeUp") printLogMsg("yes");
   }
 
-  console.log(navigator.mediaSession);
-
-  // listening for hardware buttons
-  navigator.mediaSession.setActionHandler("previoustrack", () => {
-    const logEl = document.getElementById("log");
-    logEl.innerHTML += "volumeup";
-  });
-
   // const toggleButtonControlsEl = document.getElementById(
   //   "toggle-button-controls"
   // );
@@ -491,7 +529,7 @@ function addVoiceControls() {
     setTimeout(() => {
       recognition.start();
       printLogMsg("Voice > listening");
-    }, "500");
+    }, "1000");
   };
 
   recognition.onnomatch = function (event) {
@@ -513,7 +551,10 @@ function addVoiceControls() {
 
     // add custom commands
     availableCommands.push({ command: "exit", path: "/" });
-    availableCommands.push({ command: "emergency call", path: "/menu/call/emergency" });
+    availableCommands.push({
+      command: "emergency call",
+      path: "/menu/call/emergency",
+    });
 
     // form commands array for grammarlist
     commands = availableCommands.map((x) => x.command);
@@ -521,9 +562,101 @@ function addVoiceControls() {
   }
 }
 
+// add motion controls
+// ===================
+function addMotionControls() {
+  let pointOfReference;
+  let vectorToReference;
+  let screenCenter;
+
+  // create canvas element for motion visualization
+  const canvasEl = document.createElement("canvas");
+  canvasEl.id = "canvas";
+  canvasEl.width = window.innerWidth;
+  canvasEl.height = window.innerHeight;
+  document.getElementById("app").appendChild(canvasEl);
+
+  //available headset sensors: accelerometer, gyroscope, relativeOrientation, linearAcceleration, GravitySensor
+  //unavailable headset sensors: magnetometer (this results in horizontal drifting)
+  const options = { frequency: 20, referenceFrame: "device" };
+  const sensor = new RelativeOrientationSensor(options);
+  sensor.addEventListener("reading", onChangeOrientation);
+
+  // get sensor permission via Permission API
+  Promise.all([
+    navigator.permissions.query({ name: "accelerometer" }),
+    navigator.permissions.query({ name: "gyroscope" }),
+  ]).then((results) => {
+    if (results.every((result) => result.state === "granted")) sensor.start();
+    else printLogMsg("No permissions to use RelativeOrientationSensor.");
+  });
+
+  // handle motion sensor event
+  function onChangeOrientation(event) {
+    let quaternion = event.target.quaternion;
+    if (pointOfReference === undefined) setPointOfReference(quaternion);
+
+    let mapped = {
+      x: quaternion[0],
+      y: quaternion[1],
+      z: quaternion[2],
+      w: quaternion[3],
+    };
+
+    let targetPoint = new Point(mapped.x, mapped.y);
+    let motionVector = new Vector(pointOfReference, targetPoint);
+    motionVector = motionVector.subtract(vectorToReference);
+    motionVector = motionVector.scale(200);
+    motionVector = motionVector.add(
+      new Vector(
+        new Point(),
+        new Point(canvasEl.width / 2, canvasEl.height / 2)
+      )
+    );
+
+    // focus closest item
+    // ADD THRESHOLD BEFORE TRIGGERING!
+    focusClosestItem(motionVector);
+
+    drawMotion(motionVector);
+    // printLogMsg(`${motionVector.magnitude.toFixed(2)}`);
+  }
+
+  // set new point of reference
+  function setPointOfReference(quaternion) {
+    pointOfReference = new Point(quaternion[0], quaternion[1]);
+    vectorToReference = new Vector(new Point(), pointOfReference);
+  }
+
+  function drawMotion(vector) {
+    if (canvasEl.getContext) {
+      const ctx = canvasEl.getContext("2d");
+      const pointSize = 10;
+
+      ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
+
+      ctx.strokeStyle = "#FFFFFF";
+      ctx.fillStyle = "#FFFFFF";
+      ctx.lineWidth = 2;
+
+      ctx.beginPath();
+      ctx.moveTo(canvasEl.width / 2, canvasEl.height / 2);
+      ctx.lineTo(vector.x, vector.y);
+      ctx.stroke();
+      ctx.fillRect(
+        vector.x - pointSize / 2,
+        vector.y - pointSize / 2,
+        pointSize,
+        pointSize
+      );
+    }
+  }
+}
+
 // printing messages on screen for debugging
 function printLogMsg(message) {
   const logEl = document.getElementById("log");
+  if (logEl.scrollHeight >= 10000) logEl.innerHTML = "";
   logEl.innerHTML += `${message}<br>`;
   logEl.scrollTop = logEl.scrollHeight;
   console.log(message);
diff --git a/src/modules/Vector.js b/src/modules/Vector.js
new file mode 100644
index 0000000..9ba9b00
--- /dev/null
+++ b/src/modules/Vector.js
@@ -0,0 +1,60 @@
+// point class
+export class Point {
+  constructor(x = 0, y = 0) {
+    this.x = x;
+    this.y = y;
+  }
+
+  // set new position
+  setPosition(x, y) {
+    this.x = x;
+    this.y = y;
+  }
+}
+
+// vector class
+export class Vector {
+  constructor(pointA = new Point(), pointB = new Point()) {
+    this.x = pointB.x - pointA.x;
+    this.y = pointB.y - pointA.y;
+  }
+
+  // get magnitude
+  get magnitude() {
+    return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2));
+  }
+
+  // get direction
+  get direction() {
+    return Math.atan2(this.y, this.x);
+  }
+
+  // get unit direction
+  get unit() {
+    const magnitude = this.magnitude;
+    if (magnitude === 0)
+      throw new Error("cannot get unit vector of zero vector");
+    return new Vector(
+      new Point(),
+      new Point(this.x / magnitude, this.y / magnitude)
+    );
+  }
+
+  subtract(otherVector) {
+    return new Vector(
+      new Point(),
+      new Point(this.x - otherVector.x, this.y - otherVector.y)
+    );
+  }
+
+  add(otherVector) {
+    return new Vector(
+      new Point(),
+      new Point(this.x + otherVector.x, this.y + otherVector.y)
+    );
+  }
+
+  scale(factor) {
+    return new Vector(new Point(), new Point(this.x * factor, this.y * factor));
+  }
+}
diff --git a/src/modules/helpers.js b/src/modules/helpers.js
index eed7762..52b23ae 100644
--- a/src/modules/helpers.js
+++ b/src/modules/helpers.js
@@ -25,4 +25,9 @@ export function findNestedObject(obj, key, value) {
       }
     }
   }
+}
+
+// clamp number
+export function clamp(num, min, max) {
+  return Math.min(Math.max(num, min), max);
 }
\ No newline at end of file
diff --git a/src/styles/config.css b/src/styles/config.css
index 49f3f8f..ae105d3 100644
--- a/src/styles/config.css
+++ b/src/styles/config.css
@@ -2,16 +2,20 @@ html {
   box-sizing: border-box;
 }
 
+html,
+body {
+  margin: 0;
+  padding: 0;
+  width: 100%;
+  height: 100%;
+}
+
 *,
 *:after,
 *:before {
   box-sizing: inherit;
 }
 
-body {
-  margin: 0;
-}
-
 ul {
   margin: 0;
   padding: 0;
diff --git a/src/styles/main.css b/src/styles/main.css
index 28043e6..3bfa939 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -1,8 +1,10 @@
 @import "config.css";
 #app {
+  position: relative;
   color: var(--color-white);
   background: black;
   height: 100vh;
+  width: 100vw;
   font: 300 5vw/1.2em Roboto, sans-serif;
   display: grid;
   grid-template-columns: 1fr;
@@ -10,6 +12,7 @@
   grid-template-areas: "main";
   justify-items: center;
   align-items: center;
+  padding: 100px;
 }
 @keyframes slideIn {
   from {
@@ -80,6 +83,15 @@
   opacity: 1;
   background-position: center center;
 }
+#canvas {
+  display: block;
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: 0;
+  pointer-events: none;
+}
 aside {
   display: flex;
   flex-direction: column;
@@ -88,9 +100,9 @@ aside {
   right: 0;
 }
 #log {
-  color: hsl(0, 100%, 30%);
+  color: hsl(0, 100%, 80%);
   position: absolute;
-  font: normal 0.6em/1.2em sans-serif;
+  font: normal 1em/1.2em sans-serif;
   top: 0;
   left: 0;
   max-height: 20vh;
diff --git a/src/styles/main.less b/src/styles/main.less
index 9e2e101..c0e7cdf 100644
--- a/src/styles/main.less
+++ b/src/styles/main.less
@@ -1,9 +1,11 @@
 @import "config.css";
 
 #app {
+  position: relative;
   color: var(--color-white);
   background: black;
   height: 100vh;
+  width: 100vw;
   font: 300 5vw/1.2em Roboto, sans-serif;
   display: grid;
   grid-template-columns: 1fr;
@@ -11,6 +13,7 @@
   grid-template-areas: "main";
   justify-items: center;
   align-items: center;
+  padding: 100px;
 }
 
 @keyframes slideIn {
@@ -94,6 +97,16 @@
   }
 }
 
+#canvas {
+  display: block;
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: 0;
+  pointer-events: none;
+}
+
 aside {
   display: flex;
   flex-direction: column;
@@ -103,9 +116,9 @@ aside {
 }
 
 #log {
-  color: hsl(0 100% 30%);
+  color: hsl(0 100% 80%);
   position: absolute;
-  font: normal 0.6em/1.2em sans-serif;
+  font: normal 1em/1.2em sans-serif;
   top: 0;
   left: 0;
   max-height: 20vh;
-- 
GitLab