From 426c9c51908098334f5b5e6d97573e9ecdb06a5f Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 10 Jun 2023 21:32:35 -0700 Subject: [PATCH 01/10] Reduce code duplication by making a `Wheel` class --- source/Zodiac_compatibility/data/dataArray.js | 101 ++------- source/Zodiac_compatibility/script.js | 191 +++++++++--------- source/Zodiac_compatibility/zodiac-angles.js | 46 ++--- 3 files changed, 122 insertions(+), 216 deletions(-) diff --git a/source/Zodiac_compatibility/data/dataArray.js b/source/Zodiac_compatibility/data/dataArray.js index 546a11ff..bd983080 100644 --- a/source/Zodiac_compatibility/data/dataArray.js +++ b/source/Zodiac_compatibility/data/dataArray.js @@ -1,84 +1,20 @@ /** - * A mapping of modulo'd angles to Zodiacs for the left wheel. - * @type {[number, string][]} + * The order of the zodiacs along a wheel. + * @type {string[]} */ -const zodiacAngleMappingLeft = [ - [0, "Capricorn"], - [360, "Capricorn"], - - [30, "Sagittarius"], - [-330, "Sagittarius"], - - [60, "Scorpio"], - [-300, "Scorpio"], - - [90, "Libra"], - [-270, "Libra"], - - [120, "Virgo"], - [-240, "Virgo"], - - [150, "Leo"], - [-210, "Leo"], - - [180, "Cancer"], - [-180, "Cancer"], - - [210, "Gemini"], - [-150, "Gemini"], - - [240, "Taurus"], - [-120, "Taurus"], - - [270, "Aries"], - [-90, "Aries"], - - [300, "Pisces"], - [-60, "Pisces"], - - [330, "Aquarius"], - [-30, "Aquarius"], -]; -/** - * A mapping of modulo'd angles to Zodiacs for the right wheel. - * @type {[number, string][]} - */ -const zodiacAngleMappingRight = [ - [0, "Cancer"], - [360, "Cancer"], - - [30, "Gemini"], - [-330, "Gemini"], - - [60, "Taurus"], - [-300, "Taurus"], - - [90, "Aries"], - [-270, "Aries"], - - [120, "Pisces"], - [-240, "Pisces"], - - [150, "Aquarius"], - [-210, "Aquarius"], - - [180, "Capricorn"], - [-180, "Capricorn"], - - [210, "Sagittarius"], - [-150, "Sagittarius"], - - [240, "Scorpio"], - [-120, "Scorpio"], - - [270, "Libra"], - [-90, "Libra"], - - [300, "Virgo"], - [-60, "Virgo"], - - [330, "Leo"], - [-30, "Leo"], +const zodiacOrder = [ + "Capricorn", + "Sagittarius", + "Scorpio", + "Libra", + "Virgo", + "Leo", + "Cancer", + "Gemini", + "Taurus", + "Aries", + "Pisces", + "Aquarius", ]; /** @@ -417,9 +353,4 @@ const romantic = new Map([ "When two Pisces individuals come together in a romantic relationship, they create a deep and emotionally rich connection. Both partners have a sensitive and compassionate nature, making them highly attuned to each other's emotional needs. They understand and empathize with each other's emotions, creating a safe and nurturing space for love to flourish. Pisces partners share a strong spiritual and intuitive connection, often feeling like they can understand each other on a profound level without the need for words. They enjoy creating a romantic and dreamy atmosphere, filled with love, romance, and imaginative gestures. However, their deep emotional sensitivity can sometimes lead to emotional intensity or mood swings within the relationship. It's important for Pisces partners to practice effective communication, establish healthy boundaries, and support each other's emotional well-being. When they embrace their shared empathy, emotional depth, and spiritual connection, Pisces partners can create a truly magical and soulful romantic bond.", ], ]); -export { - zodiacAngleMappingLeft, - zodiacAngleMappingRight, - zodiacDateRanges, - romantic, -}; +export { zodiacOrder, zodiacDateRanges, romantic }; diff --git a/source/Zodiac_compatibility/script.js b/source/Zodiac_compatibility/script.js index d34e1577..b7b5478d 100644 --- a/source/Zodiac_compatibility/script.js +++ b/source/Zodiac_compatibility/script.js @@ -1,29 +1,107 @@ import { wait } from "../utils.js"; import { determineDateRangeLeft, - determineDateRangeRight, - determinePairing, getMappingLeft, - getMappingRight, roundAngle, textGenerator, } from "./zodiac-angles.js"; // Get all necessary document objects -const leftWheel = document.getElementById("left_wheel_img"); -const rightWheel = document.getElementById("right_wheel_img"); const button = document.getElementById("find-out"); const how_to = document.getElementById("how_to"); const help = document.getElementById("help"); const popup = document.getElementById("pop-up"); -// add the necessary event listeners for the wheels -leftWheel.addEventListener("wheel", rotateLeftWheel); -rightWheel.addEventListener("wheel", rotateRightWheel); -leftWheel.addEventListener("mouseout", stopRotation); -rightWheel.addEventListener("mouseout", stopRotation); +class Wheel { + elem; + dateInput; + // Set initial rotation angle of the zodiac wheel + angle = 0; + angleOffset; + + constructor(elem, dateInput, angleOffset = 0) { + this.elem = elem; + this.dateInput = dateInput; + this.angleOffset = angleOffset; + + // add the necessary event listeners for the wheel + this.elem.addEventListener("wheel", this.#handleRotate); + this.elem.addEventListener("mouseout", this.stopRotation); + } + + getMapping() { + return getMappingLeft(roundAngle(this.angle + this.angleOffset)); + } + + #getDateRange() { + return determineDateRangeLeft(roundAngle(this.angle + this.angleOffset)); + } + + #setAngle(angle) { + this.angle = angle; + // Apply the rotation transform to the wheel element + this.elem.style.transform = `rotate(${this.angle}deg)`; + this.dateInput.value = this.#getDateRange(); + } + + /** + * Rotates the wheel based on the mouse wheel event. + * @param {WheelEvent} event - The mouse wheel event. + */ + #handleRotate = (event) => { + // Determine the direction of scrolling + const direction = Math.sign(event.deltaY); + + // Update the rotation angle based on the scrolling direction + this.#setAngle(this.angle + direction * 2); + + // Prevent the default scrolling behavior + event.preventDefault(); + }; + + /** + * Stops the rotation of the wheels and applies a smooth transition to the nearest rounded angle. + */ + stopRotation = () => { + // Round the current angle of the wheels to the nearest multiple of 30 + const target = roundAngle(this.angle); + + // print rounded angles for clarity + console.log( + this.elem.id, + `Wheel is rounded to ${target}: ${this.getMapping()}` + ); + + // Apply the rounded rotation transform to the wheel elements smoothly over 500ms + const interval = setInterval(() => { + if (this.angle < target) { + this.#setAngle(this.angle + 1); + } + if (this.angle > target) { + this.#setAngle(this.angle - 1); + } + if (this.angle === target) { + clearInterval(interval); + } + }, 15); + }; +} + +const leftWheel = new Wheel( + document.getElementById("left_wheel_img"), + document.getElementById("left_birthday") +); +const rightWheel = new Wheel( + document.getElementById("right_wheel_img"), + document.getElementById("right_birthday"), + 180 +); + // add all the necessary event listeners for the buttons -button.addEventListener("mouseenter", stopRotation); +button.addEventListener("mouseenter", () => { + leftWheel.stopRotation(); + rightWheel.stopRotation(); +}); button.addEventListener("click", displayResults); how_to.addEventListener("click", () => { @@ -42,102 +120,19 @@ document.addEventListener("click", (event) => { } }); -// Set initial rotation angle of the two zodiac wheels -let leftWheelAngle = 0; -let rightWheelAngle = 0; - -/** - * Rotates the left wheel based on the mouse wheel event. - * @param {WheelEvent} event - The mouse wheel event. - */ -function rotateLeftWheel(event) { - const dateInput = document.getElementById("left_birthday"); - - // Determine the direction of scrolling - const direction = Math.sign(event.deltaY); - - // Update the rotation angle based on the scrolling direction - leftWheelAngle += direction * 2; - // Apply the rotation transform to the wheel element - leftWheel.style.transform = `rotate(${leftWheelAngle}deg)`; - - dateInput.value = determineDateRangeLeft(roundAngle(leftWheelAngle)); - - // Prevent the default scrolling behavior - event.preventDefault(); -} -/** - * Rotates the right wheel based on the mouse wheel event. - * @param {WheelEvent} event - The mouse wheel event. - */ -function rotateRightWheel(event) { - const dateInput = document.getElementById("right_birthday"); - // Determine the direction of scrolling - const direction = Math.sign(event.deltaY); - - // Update the rotation angle based on the scrolling direction - rightWheelAngle += direction * 2; - // Apply the rotation transform to the wheel element - rightWheel.style.transform = `rotate(${rightWheelAngle}deg)`; - - dateInput.value = determineDateRangeRight(roundAngle(rightWheelAngle)); - - // Prevent the default scrolling behavior - event.preventDefault(); -} - -/** - * Stops the rotation of the wheels and applies a smooth transition to the nearest rounded angle. - */ -function stopRotation() { - // Round the current angle of the wheels to the nearest multiple of 30 - const target1 = roundAngle(leftWheelAngle); - const target2 = roundAngle(rightWheelAngle); - - // print rounded angles for clarity - console.log( - `Left Wheel is rounded to ${target1}: ${getMappingLeft(target1)}` - ); - console.log( - `Right Wheel is rounded to ${target2}: ${getMappingRight(target2)}` - ); - - // Apply the rounded rotation transform to the wheel elements smoothly over 500ms - const interval = setInterval(() => { - if (leftWheelAngle < target1) { - leftWheelAngle += 1; - leftWheel.style.transform = `rotate(${leftWheelAngle}deg)`; - } - if (leftWheelAngle > target1) { - leftWheelAngle -= 1; - leftWheel.style.transform = `rotate(${leftWheelAngle}deg)`; - } - if (rightWheelAngle < target2) { - rightWheelAngle += 1; - rightWheel.style.transform = `rotate(${rightWheelAngle}deg)`; - } - if (rightWheelAngle > target2) { - rightWheelAngle -= 1; - rightWheel.style.transform = `rotate(${rightWheelAngle}deg)`; - } - if (leftWheelAngle === target1 && rightWheelAngle === target2) { - clearInterval(interval); - } - }, 15); -} - /** * Displays the results of the pairing and animates the UI elements. */ async function displayResults() { - const pair = determinePairing(leftWheelAngle, rightWheelAngle); + const left = leftWheel.getMapping(); + const right = rightWheel.getMapping(); // slide off or fade all of the elements on the page to make room for results popup document.body.classList.add("remove-wheels"); const pairingHeader = popup.querySelector("#pairing"); - pairingHeader.textContent = pair[0] + " and " + pair[1]; + pairingHeader.textContent = left + " and " + right; const pairing_text = popup.querySelector("#pairing_text"); - pairing_text.innerHTML = textGenerator(pair[0], pair[1]); + pairing_text.innerHTML = textGenerator(left, right); /** * Displays the popup with the pairing information after a delay. diff --git a/source/Zodiac_compatibility/zodiac-angles.js b/source/Zodiac_compatibility/zodiac-angles.js index e23aa34c..a3fa15ee 100644 --- a/source/Zodiac_compatibility/zodiac-angles.js +++ b/source/Zodiac_compatibility/zodiac-angles.js @@ -1,11 +1,7 @@ // @ts-check -import { - zodiacAngleMappingLeft, - zodiacAngleMappingRight, - zodiacDateRanges, - romantic, -} from "./data/dataArray.js"; +import { mod } from "../utils.js"; +import { zodiacOrder, zodiacDateRanges, romantic } from "./data/dataArray.js"; /** * Rounds the given angle to the nearest multiple of 30. @@ -13,47 +9,33 @@ import { * @returns {number} The rounded angle. */ export function roundAngle(angle) { - let base = Math.floor(angle / 360); - let rem = angle % 360; - if (angle >= 0) { - return base * 360 + Math.round(rem / 30) * 30; - } else { - base = Math.ceil(angle / 360); - //console.log(base + ';' + rem) - return base * 360 + Math.round(rem / 30) * 30; - } + return Math.round(angle / 30) * 30; } /** * Retrieves the zodiac sign mapping for the given angle on the left wheel. * @param {number} roundedAngle - The angle on the left wheel pre-rounded to the nearest 30 degree increment - * @returns {string} The corresponding zodiac sign. + * @returns {string} The corresponding zodiac sign, or `'unknown'` if the angle does not correspond to a zodiac. */ export function getMappingLeft(roundedAngle) { - roundedAngle = roundedAngle % 360; - for (let i = 0; i < zodiacAngleMappingLeft.length; i++) { - if (roundedAngle === zodiacAngleMappingLeft[i][0]) { - // @ts-ignore - return zodiacAngleMappingLeft[i][1]; - } + if (Math.round(roundedAngle / 30) * 30 !== roundedAngle) { + return "unknown"; } - return "unknown"; + const index = Math.round(mod(roundedAngle, 360) / 30); + return zodiacOrder[index < 12 ? Math.round(mod(roundedAngle, 360) / 30) : 0]; } /** * Retrieves the zodiac sign mapping for the given angle on the right wheel. * @param {number} roundedAngle - The angle on the right wheel pre-rounded to the nearest 30 degree increment - * @returns {string} The corresponding zodiac sign. + * @returns {string} The corresponding zodiac sign, or `'unknown'` if the angle does not correspond to a zodiac. */ export function getMappingRight(roundedAngle) { - roundedAngle = roundedAngle % 360; - for (let i = 0; i < zodiacAngleMappingRight.length; i++) { - if (roundedAngle === zodiacAngleMappingRight[i][0]) { - // @ts-ignore - return zodiacAngleMappingRight[i][1]; - } + if (Math.round(roundedAngle / 30) * 30 !== roundedAngle) { + return "unknown"; } - return "unknown"; + const index = Math.round(mod(roundedAngle, 360) / 30); + return zodiacOrder[index < 6 ? index + 6 : index < 12 ? index - 6 : 0]; } /** @@ -81,8 +63,6 @@ export function determineDateRangeRight(angle) { * @returns {Array} An array containing the zodiac sign pair. */ export function determinePairing(angleLeft, angleRight) { - angleLeft = angleLeft % 360; - angleRight = angleRight % 360; const leftMapping = getMappingLeft(angleLeft); const rightMapping = getMappingRight(angleRight); return [leftMapping, rightMapping]; From 77c6c4226c5aeda9055f4ba7b388dbf8122f531a Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 10 Jun 2023 22:35:32 -0700 Subject: [PATCH 02/10] Drag the wheels with momentum --- source/Zodiac_compatibility/script.js | 231 ++++++++++++++++++++----- source/Zodiac_compatibility/styles.css | 8 +- 2 files changed, 193 insertions(+), 46 deletions(-) diff --git a/source/Zodiac_compatibility/script.js b/source/Zodiac_compatibility/script.js index b7b5478d..79eccc04 100644 --- a/source/Zodiac_compatibility/script.js +++ b/source/Zodiac_compatibility/script.js @@ -1,3 +1,5 @@ +// @ts-check + import { wait } from "../utils.js"; import { determineDateRangeLeft, @@ -12,78 +14,221 @@ const how_to = document.getElementById("how_to"); const help = document.getElementById("help"); const popup = document.getElementById("pop-up"); +/** + * @typedef {object} PointerInfo + * @property {number} pointerId + * @property {number} initWheelAngle + * @property {number} initMouseAngle + * @property {number} lastMouseAngle2 + * @property {number} lastTime2 + * @property {number} lastMouseAngle1 + * @property {number} lastTime1 + */ + +/** + * @typedef {object} AnimInfo + * @property {number} frameId + * @property {number} lastTime + */ + class Wheel { - elem; - dateInput; - // Set initial rotation angle of the zodiac wheel - angle = 0; - angleOffset; + /** + * In degrees/ms^2. + * @type {number} + */ + static #FRICTION = 0.001; + /** + * @type {HTMLElement} + */ + #elem; + /** + * @type {HTMLInputElement} + */ + #dateInput; + /** + * @type {number} + */ + #angle = 0; + /** + * @type {number} + */ + #angleOffset; + /** + * @type {PointerInfo | null} + */ + #pointer = null; + /** + * @type {number} + */ + #angleVel = 0; + /** + * @type {AnimInfo | null} + */ + #animating = null; + + /** + * @param {HTMLElement} elem + * @param {HTMLInputElement} dateInput + * @param {number} angleOffset + */ constructor(elem, dateInput, angleOffset = 0) { - this.elem = elem; - this.dateInput = dateInput; - this.angleOffset = angleOffset; + this.#elem = elem; + this.#dateInput = dateInput; + this.#angleOffset = angleOffset; // add the necessary event listeners for the wheel - this.elem.addEventListener("wheel", this.#handleRotate); - this.elem.addEventListener("mouseout", this.stopRotation); + this.#elem.addEventListener("wheel", this.#handleWheel); + this.#elem.addEventListener("pointerdown", this.#handlePointerDown); + this.#elem.addEventListener("pointermove", this.#handlePointerMove); + this.#elem.addEventListener("pointerup", this.#handlePointerUp); + this.#elem.addEventListener("pointercancel", this.#handlePointerUp); } getMapping() { - return getMappingLeft(roundAngle(this.angle + this.angleOffset)); + return getMappingLeft(roundAngle(this.#angle + this.#angleOffset)); } #getDateRange() { - return determineDateRangeLeft(roundAngle(this.angle + this.angleOffset)); + return determineDateRangeLeft(roundAngle(this.#angle + this.#angleOffset)); } #setAngle(angle) { - this.angle = angle; + if (!Number.isFinite(angle)) { + throw new RangeError(`Expected a numerical angle. Received ${angle}`); + } + this.#angle = angle; // Apply the rotation transform to the wheel element - this.elem.style.transform = `rotate(${this.angle}deg)`; - this.dateInput.value = this.#getDateRange(); + this.#elem.style.transform = `rotate(${this.#angle}deg)`; + this.#dateInput.value = this.#getDateRange(); + } + + /** + * @param {PointerEvent} event + * @returns {number} Clockwise, between -180° and 180°, where 0° means the + * mouse is right of the center. + */ + #getMouseAngle(event) { + const rect = this.#elem.getBoundingClientRect(); + // y is first + return ( + Math.atan2( + event.clientY - (rect.top + rect.height / 2), + event.clientX - (rect.left + rect.width / 2) + ) * + (180 / Math.PI) + ); } + /** + * @param {PointerEvent} event + */ + #handlePointerDown = (event) => { + if (!this.#pointer) { + const mouseAngle = this.#getMouseAngle(event); + this.#pointer = { + pointerId: event.pointerId, + initWheelAngle: this.#angle, + initMouseAngle: mouseAngle, + lastMouseAngle2: mouseAngle, + lastTime2: Date.now(), + lastMouseAngle1: mouseAngle, + lastTime1: Date.now(), + }; + this.#elem.setPointerCapture(event.pointerId); + this.#stopMomentum(); + } + }; + + /** + * @param {PointerEvent} event + */ + #handlePointerMove = (event) => { + if (this.#pointer?.pointerId === event.pointerId) { + const mouseAngle = this.#getMouseAngle(event); + this.#pointer.lastMouseAngle2 = this.#pointer.lastMouseAngle1; + this.#pointer.lastTime2 = this.#pointer.lastTime1; + this.#pointer.lastMouseAngle1 = mouseAngle; + this.#pointer.lastTime1 = Date.now(); + this.#setAngle( + mouseAngle - this.#pointer.initMouseAngle + this.#pointer.initWheelAngle + ); + } + }; + + /** + * @param {PointerEvent} event + */ + #handlePointerUp = (event) => { + if (this.#pointer?.pointerId === event.pointerId) { + let angleDiff = + this.#pointer.lastMouseAngle1 - this.#pointer.lastMouseAngle2; + if (angleDiff > 180) { + angleDiff -= 360; + } else if (angleDiff < -180) { + angleDiff += 360; + } + const timeDiff = this.#pointer.lastTime1 - this.#pointer.lastTime2; + this.#pointer = null; + + if (timeDiff > 0) { + this.#angleVel = angleDiff / timeDiff; + this.#startMomentum(); + } + } + }; + /** * Rotates the wheel based on the mouse wheel event. * @param {WheelEvent} event - The mouse wheel event. */ - #handleRotate = (event) => { + #handleWheel = (event) => { // Determine the direction of scrolling const direction = Math.sign(event.deltaY); // Update the rotation angle based on the scrolling direction - this.#setAngle(this.angle + direction * 2); + this.#setAngle(this.#angle + direction * 2); // Prevent the default scrolling behavior event.preventDefault(); }; - /** - * Stops the rotation of the wheels and applies a smooth transition to the nearest rounded angle. - */ - stopRotation = () => { - // Round the current angle of the wheels to the nearest multiple of 30 - const target = roundAngle(this.angle); - - // print rounded angles for clarity - console.log( - this.elem.id, - `Wheel is rounded to ${target}: ${this.getMapping()}` - ); + #startMomentum() { + if (!this.#animating) { + this.#animating = { + frameId: 0, + lastTime: Date.now(), + }; + this.#paint(); + } + } - // Apply the rounded rotation transform to the wheel elements smoothly over 500ms - const interval = setInterval(() => { - if (this.angle < target) { - this.#setAngle(this.angle + 1); - } - if (this.angle > target) { - this.#setAngle(this.angle - 1); - } - if (this.angle === target) { - clearInterval(interval); - } - }, 15); + #stopMomentum() { + if (this.#animating) { + window.cancelAnimationFrame(this.#animating.frameId); + this.#animating = null; + } + } + + #paint = () => { + if (!this.#animating) { + return; + } + const now = Date.now(); + const elapsed = Math.min(now - this.#animating.lastTime, 200); + this.#animating.lastTime = now; + if (this.#angleVel > 0) { + this.#angleVel = Math.max(this.#angleVel - Wheel.#FRICTION * elapsed, 0); + } else { + this.#angleVel = Math.min(this.#angleVel + Wheel.#FRICTION * elapsed, 0); + } + if (this.#angleVel === 0) { + this.#animating = null; + // TODO + return; + } + this.#setAngle(this.#angle + this.#angleVel * elapsed); + this.#animating.frameId = window.requestAnimationFrame(this.#paint); }; } @@ -98,10 +243,6 @@ const rightWheel = new Wheel( ); // add all the necessary event listeners for the buttons -button.addEventListener("mouseenter", () => { - leftWheel.stopRotation(); - rightWheel.stopRotation(); -}); button.addEventListener("click", displayResults); how_to.addEventListener("click", () => { diff --git a/source/Zodiac_compatibility/styles.css b/source/Zodiac_compatibility/styles.css index 1b0456f0..62336809 100644 --- a/source/Zodiac_compatibility/styles.css +++ b/source/Zodiac_compatibility/styles.css @@ -70,7 +70,13 @@ option { max-height: 100%; min-width: 100%; min-height: 100%; - /* transition: 0.5s ease; */ + touch-action: none; + -webkit-user-drag: none; + user-drag: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; } #left_wheel_field { From aff5e215a5fc717f84f11e81ae8d370c40aa3297 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 10 Jun 2023 23:14:42 -0700 Subject: [PATCH 03/10] ChatGPT is not giving good easing approximations --- __tests__/zodiacUnit.test.js | 107 +++++++++++++++++++ source/Zodiac_compatibility/script.js | 104 ++++++++++++------ source/Zodiac_compatibility/zodiac-angles.js | 57 ++++++++++ 3 files changed, 237 insertions(+), 31 deletions(-) diff --git a/__tests__/zodiacUnit.test.js b/__tests__/zodiacUnit.test.js index 156733db..972af295 100644 --- a/__tests__/zodiacUnit.test.js +++ b/__tests__/zodiacUnit.test.js @@ -6,6 +6,8 @@ import { determineDateRangeRight, determinePairing, textGenerator, + angleDiff, + ease, } from "../source/Zodiac_compatibility/zodiac-angles.js"; describe("roundAngle Tests", () => { @@ -201,3 +203,108 @@ describe("textGenerator Tests", () => { expect(textGenerator("unknown", "unknown")).toBe("An error has occurred"); }); }); + +// Generated by ChatGPT, except some of its expectations were wrong. +describe("angleDiff", () => { + test("returns the correct difference when angle > base", () => { + const angle = 120; + const base = 30; + const result = angleDiff(angle, base); + expect(result).toBe(90); + }); + + test("returns the correct difference when angle < base", () => { + const angle = 30; + const base = 120; + const result = angleDiff(angle, base); + expect(result).toBe(-90); + }); + + test("returns 0 when angle and base are equal", () => { + const angle = 90; + const base = 90; + const result = angleDiff(angle, base); + expect(result).toBe(0); + }); + + test("returns the correct difference when angle and base are opposite", () => { + const angle = -170; + const base = 170; + const result = angleDiff(angle, base); + expect(result).toBe(20); + }); + + test("returns the correct difference when angle and base are negative", () => { + const angle = -30; + const base = -60; + const result = angleDiff(angle, base); + expect(result).toBe(30); + }); + + test("returns the correct difference when angle and base are positive", () => { + const angle = 200; + const base = 100; + const result = angleDiff(angle, base); + expect(result).toBe(100); + }); + + test("returns the correct difference when angle is near 180° and base is negative", () => { + const angle = 179; + const base = -179; + const result = angleDiff(angle, base); + expect(result).toBe(-2); + }); + + test("returns the correct difference when angle is near -180° and base is positive", () => { + const angle = -179; + const base = 179; + const result = angleDiff(angle, base); + expect(result).toBe(2); + }); + + test("returns the correct difference when angle is near 180° and base is positive", () => { + const angle = 179; + const base = 1; + const result = angleDiff(angle, base); + expect(result).toBe(178); + }); + + test("returns the correct difference when angle is near -180° and base is negative", () => { + const angle = -179; + const base = -1; + const result = angleDiff(angle, base); + expect(result).toBe(-178); + }); +}); + +describe("ease", () => { + test("returns the correct value at t = 0", () => { + const t = 0; + const result = ease(t); + expect(result).toBe(0); + }); + + test("returns the correct value at t = 0.25", () => { + const t = 0.25; + const result = ease(t); + expect(result).toBeCloseTo(0.41); + }); + + test("returns the correct value at t = 0.5", () => { + const t = 0.5; + const result = ease(t); + expect(result).toBeCloseTo(0.8); + }); + + test("returns the correct value at t = 0.75", () => { + const t = 0.75; + const result = ease(t); + expect(result).toBeCloseTo(0.96); + }); + + test("returns the correct value at t = 1", () => { + const t = 1; + const result = ease(t); + expect(result).toBe(1); + }); +}); diff --git a/source/Zodiac_compatibility/script.js b/source/Zodiac_compatibility/script.js index 79eccc04..cd0bec43 100644 --- a/source/Zodiac_compatibility/script.js +++ b/source/Zodiac_compatibility/script.js @@ -2,7 +2,9 @@ import { wait } from "../utils.js"; import { + angleDiff, determineDateRangeLeft, + ease, getMappingLeft, roundAngle, textGenerator, @@ -26,9 +28,20 @@ const popup = document.getElementById("pop-up"); */ /** - * @typedef {object} AnimInfo + * @typedef {object} MomentumInfo + * @property {'momentum'} type * @property {number} frameId * @property {number} lastTime + * @property {number} angleVel + */ + +/** + * @typedef {object} SnapInfo + * @property {'snap'} type + * @property {number} frameId + * @property {number} startTime + * @property {number} startAngle + * @property {number} targetAngle */ class Wheel { @@ -37,6 +50,11 @@ class Wheel { * @type {number} */ static #FRICTION = 0.001; + /** + * In ms. + * @type {number} + */ + static #SNAP_DURATION = 500; /** * @type {HTMLElement} @@ -59,11 +77,7 @@ class Wheel { */ #pointer = null; /** - * @type {number} - */ - #angleVel = 0; - /** - * @type {AnimInfo | null} + * @type {MomentumInfo | SnapInfo | null} */ #animating = null; @@ -161,20 +175,16 @@ class Wheel { */ #handlePointerUp = (event) => { if (this.#pointer?.pointerId === event.pointerId) { - let angleDiff = - this.#pointer.lastMouseAngle1 - this.#pointer.lastMouseAngle2; - if (angleDiff > 180) { - angleDiff -= 360; - } else if (angleDiff < -180) { - angleDiff += 360; - } - const timeDiff = this.#pointer.lastTime1 - this.#pointer.lastTime2; + this.#startMomentum( + this.#pointer.lastTime1 > this.#pointer.lastTime2 + ? angleDiff( + this.#pointer.lastMouseAngle1, + this.#pointer.lastMouseAngle2 + ) / + (this.#pointer.lastTime1 - this.#pointer.lastTime2) + : 0 + ); this.#pointer = null; - - if (timeDiff > 0) { - this.#angleVel = angleDiff / timeDiff; - this.#startMomentum(); - } } }; @@ -193,11 +203,16 @@ class Wheel { event.preventDefault(); }; - #startMomentum() { + /** + * @param {number} angleVel + */ + #startMomentum(angleVel = 0) { if (!this.#animating) { this.#animating = { + type: "momentum", frameId: 0, lastTime: Date.now(), + angleVel, }; this.#paint(); } @@ -215,19 +230,46 @@ class Wheel { return; } const now = Date.now(); - const elapsed = Math.min(now - this.#animating.lastTime, 200); - this.#animating.lastTime = now; - if (this.#angleVel > 0) { - this.#angleVel = Math.max(this.#angleVel - Wheel.#FRICTION * elapsed, 0); - } else { - this.#angleVel = Math.min(this.#angleVel + Wheel.#FRICTION * elapsed, 0); + if (this.#animating.type === "momentum") { + const elapsed = Math.min(now - this.#animating.lastTime, 200); + this.#animating.lastTime = now; + if (this.#animating.angleVel > 0) { + this.#animating.angleVel = Math.max( + this.#animating.angleVel - Wheel.#FRICTION * elapsed, + 0 + ); + } else { + this.#animating.angleVel = Math.min( + this.#animating.angleVel + Wheel.#FRICTION * elapsed, + 0 + ); + } + if (this.#animating.angleVel === 0) { + this.#animating = { + type: "snap", + frameId: this.#animating.frameId, + startTime: now, + startAngle: this.#angle, + targetAngle: roundAngle(this.#angle), + }; + } else { + this.#setAngle(this.#angle + this.#animating.angleVel * elapsed); + } } - if (this.#angleVel === 0) { - this.#animating = null; - // TODO - return; + if (this.#animating.type === "snap") { + const progress = (now - this.#animating.startTime) / Wheel.#SNAP_DURATION; + if (progress >= 1) { + this.#setAngle(this.#animating.targetAngle); + this.#animating = null; + return; + } else { + this.#setAngle( + this.#animating.startAngle + + ease(progress) * + angleDiff(this.#animating.targetAngle, this.#animating.startAngle) + ); + } } - this.#setAngle(this.#angle + this.#angleVel * elapsed); this.#animating.frameId = window.requestAnimationFrame(this.#paint); }; } diff --git a/source/Zodiac_compatibility/zodiac-angles.js b/source/Zodiac_compatibility/zodiac-angles.js index a3fa15ee..7dadc949 100644 --- a/source/Zodiac_compatibility/zodiac-angles.js +++ b/source/Zodiac_compatibility/zodiac-angles.js @@ -81,3 +81,60 @@ export function textGenerator(leftSign, rightSign) { "An error has occurred" ); } + +/** + * Finds the smaller difference between two angles. For example, the difference + * -170° - 170° should be 20°, because -170° is equivalent to 190°. + * @param {number} angle - The angle to subtract the base from. + * @param {number} base - The base angle that is subtracted from the angle. + * @returns {number} `angle - base`, but it's guaranteed to be between -180° and + * 180°. + */ +export function angleDiff(angle, base) { + const diff = mod(angle - base, 360); + return diff > 180 ? diff - 360 : diff; +} + +/** + * Calculates the default CSS transition-timing-function, `ease` + * (`cubic-bezier(0.25, 0.1, 0.25, 1.0)`). + * + * @param {number} t - Transition time (between 0 and 1). + * @returns {number} The interpolated value (between 0 and 1). + */ +export function ease(t) { + const ax = 0; + const ay = 0; + const bx = 0.25; + const by = 0.1; + const cx = 0.25; + const cy = 1.0; + const dx = 1.0; + const dy = 1.0; + + function cubicBezier(t) { + const t2 = t * t; + const t3 = t2 * t; + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + return ax * mt3 + 3 * bx * mt2 * t + 3 * cx * mt * t2 + dx * t3; + } + + // Use binary search to find the approximate value of t + let start = 0; + let end = 1; + const epsilon = 0.0001; // Desired precision + + while (Math.abs(end - start) > epsilon) { + const mid = (start + end) / 2; + const value = cubicBezier(mid); + if (value < t) { + start = mid; + } else { + end = mid; + } + } + + return cubicBezier((start + end) / 2); +} From 7852ab2628edc693a7c45a8f6f6a67a838a7714c Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 10 Jun 2023 23:24:53 -0700 Subject: [PATCH 04/10] =?UTF-8?q?Use=20CSS=20transition=20easing=20instead?= =?UTF-8?q?=20=F0=9F=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/zodiacUnit.test.js | 33 ----------- source/Zodiac_compatibility/script.js | 60 +++++++------------- source/Zodiac_compatibility/zodiac-angles.js | 44 -------------- 3 files changed, 21 insertions(+), 116 deletions(-) diff --git a/__tests__/zodiacUnit.test.js b/__tests__/zodiacUnit.test.js index 972af295..51cf3d19 100644 --- a/__tests__/zodiacUnit.test.js +++ b/__tests__/zodiacUnit.test.js @@ -7,7 +7,6 @@ import { determinePairing, textGenerator, angleDiff, - ease, } from "../source/Zodiac_compatibility/zodiac-angles.js"; describe("roundAngle Tests", () => { @@ -276,35 +275,3 @@ describe("angleDiff", () => { expect(result).toBe(-178); }); }); - -describe("ease", () => { - test("returns the correct value at t = 0", () => { - const t = 0; - const result = ease(t); - expect(result).toBe(0); - }); - - test("returns the correct value at t = 0.25", () => { - const t = 0.25; - const result = ease(t); - expect(result).toBeCloseTo(0.41); - }); - - test("returns the correct value at t = 0.5", () => { - const t = 0.5; - const result = ease(t); - expect(result).toBeCloseTo(0.8); - }); - - test("returns the correct value at t = 0.75", () => { - const t = 0.75; - const result = ease(t); - expect(result).toBeCloseTo(0.96); - }); - - test("returns the correct value at t = 1", () => { - const t = 1; - const result = ease(t); - expect(result).toBe(1); - }); -}); diff --git a/source/Zodiac_compatibility/script.js b/source/Zodiac_compatibility/script.js index cd0bec43..476cd500 100644 --- a/source/Zodiac_compatibility/script.js +++ b/source/Zodiac_compatibility/script.js @@ -1,10 +1,7 @@ -// @ts-check - import { wait } from "../utils.js"; import { angleDiff, determineDateRangeLeft, - ease, getMappingLeft, roundAngle, textGenerator, @@ -35,26 +32,12 @@ const popup = document.getElementById("pop-up"); * @property {number} angleVel */ -/** - * @typedef {object} SnapInfo - * @property {'snap'} type - * @property {number} frameId - * @property {number} startTime - * @property {number} startAngle - * @property {number} targetAngle - */ - class Wheel { /** * In degrees/ms^2. * @type {number} */ static #FRICTION = 0.001; - /** - * In ms. - * @type {number} - */ - static #SNAP_DURATION = 500; /** * @type {HTMLElement} @@ -77,7 +60,7 @@ class Wheel { */ #pointer = null; /** - * @type {MomentumInfo | SnapInfo | null} + * @type {MomentumInfo | null} */ #animating = null; @@ -117,6 +100,18 @@ class Wheel { this.#dateInput.value = this.#getDateRange(); } + #getAngle() { + const matrix = window.getComputedStyle(this.#elem).transform; + const match = matrix.match(/^matrix\((-?\d+(?:\.\d+)?), (-?\d+(?:\.\d+)?)/); + if (match) { + const cosine = +match[1]; + const sine = +match[2]; + return Math.atan2(sine, cosine) * (180 / Math.PI); + } else { + return this.#angle; + } + } + /** * @param {PointerEvent} event * @returns {number} Clockwise, between -180° and 180°, where 0° means the @@ -139,10 +134,11 @@ class Wheel { */ #handlePointerDown = (event) => { if (!this.#pointer) { + const wheelAngle = this.#getAngle(); const mouseAngle = this.#getMouseAngle(event); this.#pointer = { pointerId: event.pointerId, - initWheelAngle: this.#angle, + initWheelAngle: wheelAngle, initMouseAngle: mouseAngle, lastMouseAngle2: mouseAngle, lastTime2: Date.now(), @@ -150,6 +146,8 @@ class Wheel { lastTime1: Date.now(), }; this.#elem.setPointerCapture(event.pointerId); + // Set angle again in case it was interrupted mid-transition + this.#setAngle(wheelAngle); this.#stopMomentum(); } }; @@ -223,6 +221,7 @@ class Wheel { window.cancelAnimationFrame(this.#animating.frameId); this.#animating = null; } + this.#elem.style.transition = null; } #paint = () => { @@ -245,29 +244,12 @@ class Wheel { ); } if (this.#animating.angleVel === 0) { - this.#animating = { - type: "snap", - frameId: this.#animating.frameId, - startTime: now, - startAngle: this.#angle, - targetAngle: roundAngle(this.#angle), - }; - } else { - this.#setAngle(this.#angle + this.#animating.angleVel * elapsed); - } - } - if (this.#animating.type === "snap") { - const progress = (now - this.#animating.startTime) / Wheel.#SNAP_DURATION; - if (progress >= 1) { - this.#setAngle(this.#animating.targetAngle); this.#animating = null; + this.#elem.style.transition = "transform 0.5s"; + this.#setAngle(roundAngle(this.#angle)); return; } else { - this.#setAngle( - this.#animating.startAngle + - ease(progress) * - angleDiff(this.#animating.targetAngle, this.#animating.startAngle) - ); + this.#setAngle(this.#angle + this.#animating.angleVel * elapsed); } } this.#animating.frameId = window.requestAnimationFrame(this.#paint); diff --git a/source/Zodiac_compatibility/zodiac-angles.js b/source/Zodiac_compatibility/zodiac-angles.js index 7dadc949..a3ca3087 100644 --- a/source/Zodiac_compatibility/zodiac-angles.js +++ b/source/Zodiac_compatibility/zodiac-angles.js @@ -94,47 +94,3 @@ export function angleDiff(angle, base) { const diff = mod(angle - base, 360); return diff > 180 ? diff - 360 : diff; } - -/** - * Calculates the default CSS transition-timing-function, `ease` - * (`cubic-bezier(0.25, 0.1, 0.25, 1.0)`). - * - * @param {number} t - Transition time (between 0 and 1). - * @returns {number} The interpolated value (between 0 and 1). - */ -export function ease(t) { - const ax = 0; - const ay = 0; - const bx = 0.25; - const by = 0.1; - const cx = 0.25; - const cy = 1.0; - const dx = 1.0; - const dy = 1.0; - - function cubicBezier(t) { - const t2 = t * t; - const t3 = t2 * t; - const mt = 1 - t; - const mt2 = mt * mt; - const mt3 = mt2 * mt; - return ax * mt3 + 3 * bx * mt2 * t + 3 * cx * mt * t2 + dx * t3; - } - - // Use binary search to find the approximate value of t - let start = 0; - let end = 1; - const epsilon = 0.0001; // Desired precision - - while (Math.abs(end - start) > epsilon) { - const mid = (start + end) / 2; - const value = cubicBezier(mid); - if (value < t) { - start = mid; - } else { - end = mid; - } - } - - return cubicBezier((start + end) / 2); -} From 88fc920b12ea911ebc115dfeeb78ecb1677d87b8 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 11 Jun 2023 00:02:04 -0700 Subject: [PATCH 05/10] Make scroll wheel functional again Could be nicer but dealing with anything related to scrolling is a pain in JS --- source/Zodiac_compatibility/script.js | 92 +++++++++++++++++---------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/source/Zodiac_compatibility/script.js b/source/Zodiac_compatibility/script.js index 476cd500..ceb4532b 100644 --- a/source/Zodiac_compatibility/script.js +++ b/source/Zodiac_compatibility/script.js @@ -26,7 +26,6 @@ const popup = document.getElementById("pop-up"); /** * @typedef {object} MomentumInfo - * @property {'momentum'} type * @property {number} frameId * @property {number} lastTime * @property {number} angleVel @@ -63,6 +62,10 @@ class Wheel { * @type {MomentumInfo | null} */ #animating = null; + /** + * @type {number | null} + */ + #wheelTimeout = null; /** * @param {HTMLElement} elem @@ -90,6 +93,14 @@ class Wheel { return determineDateRangeLeft(roundAngle(this.#angle + this.#angleOffset)); } + /** + * @returns {{ x: number; y: number }} + */ + #getCenter() { + const rect = this.#elem.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } + #setAngle(angle) { if (!Number.isFinite(angle)) { throw new RangeError(`Expected a numerical angle. Received ${angle}`); @@ -102,7 +113,9 @@ class Wheel { #getAngle() { const matrix = window.getComputedStyle(this.#elem).transform; - const match = matrix.match(/^matrix\((-?\d+(?:\.\d+)?), (-?\d+(?:\.\d+)?)/); + const match = matrix.match( + /^matrix\((-?\d+(?:\.\d+)?(?:e-?\d+)?), (-?\d+(?:\.\d+)?(?:e-?\d+)?)/ + ); if (match) { const cosine = +match[1]; const sine = +match[2]; @@ -118,13 +131,10 @@ class Wheel { * mouse is right of the center. */ #getMouseAngle(event) { - const rect = this.#elem.getBoundingClientRect(); - // y is first + const center = this.#getCenter(); return ( - Math.atan2( - event.clientY - (rect.top + rect.height / 2), - event.clientX - (rect.left + rect.width / 2) - ) * + // y is first + Math.atan2(event.clientY - center.y, event.clientX - center.x) * (180 / Math.PI) ); } @@ -191,11 +201,22 @@ class Wheel { * @param {WheelEvent} event - The mouse wheel event. */ #handleWheel = (event) => { + const center = this.#getCenter(); + const wheelAngle = this.#getAngle(); + // Determine the direction of scrolling - const direction = Math.sign(event.deltaY); + const direction = + Math.sign(event.deltaY) * Math.sign(center.x - event.clientX); // Update the rotation angle based on the scrolling direction - this.#setAngle(this.#angle + direction * 2); + this.#setAngle(wheelAngle + direction * 2); + console.log(wheelAngle, this.#angle); + + this.#stopMomentum(); + this.#wheelTimeout = setTimeout(() => { + this.#wheelTimeout = null; + this.#snap(); + }, 500); // Prevent the default scrolling behavior event.preventDefault(); @@ -207,7 +228,6 @@ class Wheel { #startMomentum(angleVel = 0) { if (!this.#animating) { this.#animating = { - type: "momentum", frameId: 0, lastTime: Date.now(), angleVel, @@ -221,6 +241,10 @@ class Wheel { window.cancelAnimationFrame(this.#animating.frameId); this.#animating = null; } + if (this.#wheelTimeout) { + clearTimeout(this.#wheelTimeout); + this.#wheelTimeout = null; + } this.#elem.style.transition = null; } @@ -229,31 +253,33 @@ class Wheel { return; } const now = Date.now(); - if (this.#animating.type === "momentum") { - const elapsed = Math.min(now - this.#animating.lastTime, 200); - this.#animating.lastTime = now; - if (this.#animating.angleVel > 0) { - this.#animating.angleVel = Math.max( - this.#animating.angleVel - Wheel.#FRICTION * elapsed, - 0 - ); - } else { - this.#animating.angleVel = Math.min( - this.#animating.angleVel + Wheel.#FRICTION * elapsed, - 0 - ); - } - if (this.#animating.angleVel === 0) { - this.#animating = null; - this.#elem.style.transition = "transform 0.5s"; - this.#setAngle(roundAngle(this.#angle)); - return; - } else { - this.#setAngle(this.#angle + this.#animating.angleVel * elapsed); - } + const elapsed = Math.min(now - this.#animating.lastTime, 200); + this.#animating.lastTime = now; + if (this.#animating.angleVel > 0) { + this.#animating.angleVel = Math.max( + this.#animating.angleVel - Wheel.#FRICTION * elapsed, + 0 + ); + } else { + this.#animating.angleVel = Math.min( + this.#animating.angleVel + Wheel.#FRICTION * elapsed, + 0 + ); + } + if (this.#animating.angleVel === 0) { + this.#animating = null; + this.#snap(); + return; + } else { + this.#setAngle(this.#angle + this.#animating.angleVel * elapsed); } this.#animating.frameId = window.requestAnimationFrame(this.#paint); }; + + #snap() { + this.#elem.style.transition = "transform 0.5s"; + this.#setAngle(roundAngle(this.#angle)); + } } const leftWheel = new Wheel( From b184276f4f9726c5a03a69efae38801d00bd1622 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 11 Jun 2023 14:12:03 -0700 Subject: [PATCH 06/10] Add JSDoc for the new `Wheel` class --- .jsdoc.conf.json | 1 + README.md | 1 + docs/images/docs-button.svg | 25 ++++ source/PalmReading/webcam.js | 2 +- source/Zodiac_compatibility/script.js | 162 +++++++++++++++++++++++--- 5 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 docs/images/docs-button.svg diff --git a/.jsdoc.conf.json b/.jsdoc.conf.json index 827a04c6..c2acf541 100644 --- a/.jsdoc.conf.json +++ b/.jsdoc.conf.json @@ -2,6 +2,7 @@ "plugins": [], "recurseDepth": 10, "opts": { + "private": true, "recurse": true }, "source": { diff --git a/README.md b/README.md index ec33611d..12af4f9f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Improving the student experience by alleviating decision-making anxiety. Brought to you by [20/20 Visionaries](./admin/team.md) (CSE 110 SP23 Team 20). [![Try it out button](./docs/images/try-button.svg)](https://cse110-sp23-group20.github.io/fortune-teller/source/home-page/) +[![Documentation button](./docs/images/docs-button.svg)](./JSDOCs/) [![Team page button](./docs/images/team-page-button.svg)](./admin/team.md) ## Development diff --git a/docs/images/docs-button.svg b/docs/images/docs-button.svg new file mode 100644 index 00000000..62591479 --- /dev/null +++ b/docs/images/docs-button.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/source/PalmReading/webcam.js b/source/PalmReading/webcam.js index 64391a79..176e84aa 100644 --- a/source/PalmReading/webcam.js +++ b/source/PalmReading/webcam.js @@ -146,7 +146,7 @@ readAnother.addEventListener("click", startCamera); * Some points on an image of an ECG graph I found on Google Images that I * manually marked out in MS Paint. Used to form the piecewise linear `ecg` * polyline. - * @type {[number, number][]} + * @type {number[][]} */ const ecgPoints = [ [-0.8, 0.2], diff --git a/source/Zodiac_compatibility/script.js b/source/Zodiac_compatibility/script.js index ceb4532b..8ec289b8 100644 --- a/source/Zodiac_compatibility/script.js +++ b/source/Zodiac_compatibility/script.js @@ -14,63 +14,97 @@ const help = document.getElementById("help"); const popup = document.getElementById("pop-up"); /** + * Stores information about the pointer dragging the wheel around. * @typedef {object} PointerInfo - * @property {number} pointerId - * @property {number} initWheelAngle - * @property {number} initMouseAngle - * @property {number} lastMouseAngle2 - * @property {number} lastTime2 - * @property {number} lastMouseAngle1 - * @property {number} lastTime1 + * @property {number} pointerId - The ID of the pointer. + * @property {number} initWheelAngle - The angle of the wheel when dragging + * started. + * @property {number} initMouseAngle - The polar coordinate angle of the mouse + * when dragging started. + * @property {number} lastMouseAngle2 - The second-to-last recorded mouse angle. + * @property {number} lastTime2 - The timestamp of the second-to-last + * `pointermove` event. + * @property {number} lastMouseAngle1 - The last recorded mouse angle. + * @property {number} lastTime1 - The timestamp of the last `pointermove` event. */ /** + * Stores information about the wheel momentum animation. * @typedef {object} MomentumInfo - * @property {number} frameId - * @property {number} lastTime - * @property {number} angleVel + * @property {number} frameId - The ID of the requested frame, returned by + * `window.requestAnimationFrame`, used to cancel it. + * @property {number} lastTime - The timestamp of the last animation frame. + * @property {number} angleVel - The angular velocity of the wheel, in + * degrees/ms. */ +/** + * A rotatable zodiac wheel. + * + * All units of rotation are in degrees, and units of time are in milliseconds. + */ class Wheel { /** - * In degrees/ms^2. + * The amount of friction to apply to a wheel spinning with momentum. In + * degrees/ms^2. * @type {number} */ static #FRICTION = 0.001; /** + * The wheel image that gets rotated. * @type {HTMLElement} */ #elem; /** + * The input element that displays the date range of the selected zodiac. * @type {HTMLInputElement} */ #dateInput; /** + * The rotation angle of the wheel image. + * + * Note that this may not be the angle to get the zodiac mapping from, since, + * for example, the right wheel takes the zodiac from the left side of the + * wheel. * @type {number} */ #angle = 0; /** + * An offset to add to `#angle` before determining the mapped zodiac. The + * right wheel has an offset of 180° because it takes the zodiac from the left + * side of the wheel. * @type {number} */ #angleOffset; /** + * Information about the pointer dragging the wheel, if the wheel is being + * dragged. * @type {PointerInfo | null} */ #pointer = null; /** + * Information about the wheel momentum animation, if the wheel momentum is + * being animated. * @type {MomentumInfo | null} */ #animating = null; /** + * The `setTimeout` ID of the delay after using the scroll wheel on the wheel + * before trying to snap the wheel to the closest zodiac. This timeout gets + * cleared if the user continues to scroll before the timeout runs. * @type {number | null} */ #wheelTimeout = null; /** - * @param {HTMLElement} elem - * @param {HTMLInputElement} dateInput - * @param {number} angleOffset + * Constructs a `Wheel` based on existing DOM elements. Adds event listeners + * to the wheel image. + * @param {HTMLElement} elem - The wheel image element. + * @param {HTMLInputElement} dateInput - The date input that shows the date + * range of the selected zodiac. + * @param {number} angleOffset - The offset to add to the visual rotation + * angle of the wheel before determining the mapped zodiac. Default: 0. */ constructor(elem, dateInput, angleOffset = 0) { this.#elem = elem; @@ -85,22 +119,40 @@ class Wheel { this.#elem.addEventListener("pointercancel", this.#handlePointerUp); } + /** + * Calculates the zodiac that the wheel's arrow is pointing to. If the wheel + * rotation angle is not at a perfect multiple of 30°, it will round the angle + * to determine which zodiac the arrow is pointing at. + * @returns {string} The zodiac. + */ getMapping() { return getMappingLeft(roundAngle(this.#angle + this.#angleOffset)); } + /** + * Gets the range of dates for the zodiac that the wheel's arrow is pointing + * at. + * @returns {string} A range of dates for the zodiac. + */ #getDateRange() { return determineDateRangeLeft(roundAngle(this.#angle + this.#angleOffset)); } /** - * @returns {{ x: number; y: number }} + * Gets the midpoint of the wheel image. + * @returns {{ x: number, y: number }} Coordinates in pixels relative to the + * top left of the screen. */ #getCenter() { const rect = this.#elem.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } + /** + * Sets the visual rotation angle of the wheel image (no animation). This will + * also update the zodiac date range while the wheel is rotating. + * @param {number} angle - The angle to rotate the wheel to, in degrees. + */ #setAngle(angle) { if (!Number.isFinite(angle)) { throw new RangeError(`Expected a numerical angle. Received ${angle}`); @@ -111,6 +163,18 @@ class Wheel { this.#dateInput.value = this.#getDateRange(); } + /** + * Determines the current rotation angle of the wheel image. + * + * Normally, this would be the same as `#angle`, but for snapping the wheel to + * the nearest zodiac, it uses a CSS transition to smoothly rotate to the + * nearest zodiac. + * + * However, if the user decides to start rotating the wheel in the middle of + * the transition, `#angle` will have the rounded angle. This method uses + * `getComputedStyle` to get the current angle during the transition. + * @returns {number} The visual rotation angle of the wheel image, in degrees. + */ #getAngle() { const matrix = window.getComputedStyle(this.#elem).transform; const match = matrix.match( @@ -126,7 +190,10 @@ class Wheel { } /** - * @param {PointerEvent} event + * Converts a mouse position to a polar coordinate relative to the middle of + * the wheel image, and returns the angle. + * @param {PointerEvent} event - The event object from a pointer event + * handler. * @returns {number} Clockwise, between -180° and 180°, where 0° means the * mouse is right of the center. */ @@ -140,7 +207,31 @@ class Wheel { } /** - * @param {PointerEvent} event + * Event handler for the `pointerdown` event. + * + * We're using [pointer + * events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) + * rather than [mouse events](https://javascript.info/mouse-events-basics) or + * [touch + * events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Using_Touch_Events) + * because pointer events have several advantages: + * + * - They're an all-in-one set of events that fire for both mouse cursors, + * fingers, and pens (collectively called pointers). This means that I don't + * need to add both `mousedown` and `touchstart` event listeners. + * - The `setPointerCapture` method lets me receive `pointermove` and + * `pointerup` events on the wheel image even when the pointer moves outside + * of the element. For mouse and touch events, I would have to listen for + * move events on the entire `document`. + * - Combined with CSS `touch-action: none;`, I don't need to use + * `event.preventDefault()` to prevent scrolling, which would also require + * `{ passive: true }` when using touch events. + * - Finally, the `pointercancel` event makes it nice in case the user holds + * down their mouse and leaves the page. For mouse events, it will simply + * not fire any event when this happens, so to the user when they return, + * it'll look like the wheel is sticking to their cursor even though they + * aren't holding it down. + * @param {PointerEvent} event - Event object. */ #handlePointerDown = (event) => { if (!this.#pointer) { @@ -163,7 +254,8 @@ class Wheel { }; /** - * @param {PointerEvent} event + * Event handler for the `pointermove` event. + * @param {PointerEvent} event - Event object. */ #handlePointerMove = (event) => { if (this.#pointer?.pointerId === event.pointerId) { @@ -179,6 +271,8 @@ class Wheel { }; /** + * Event handler for the `pointerup` and `pointercancel` events. The latter + * occurs if the user holds their mouse down then switches tabs. * @param {PointerEvent} event */ #handlePointerUp = (event) => { @@ -223,7 +317,9 @@ class Wheel { }; /** - * @param {number} angleVel + * Starts rotating the wheel with momentum given an initial angular velocity. + * @param {number} angleVel - The initial angular velocity, in degrees/ms. + * Default: 0. */ #startMomentum(angleVel = 0) { if (!this.#animating) { @@ -236,6 +332,14 @@ class Wheel { } } + /** + * Stops all animations relating to the wheel moving on its own, such as the + * wheel rotating with momentum or automatically snapping to the nearest + * zodiac. + * + * This is called when the user starts rotating the wheel to prevent the user + * and the website from fighting over control of the wheel. + */ #stopMomentum() { if (this.#animating) { window.cancelAnimationFrame(this.#animating.frameId); @@ -248,6 +352,11 @@ class Wheel { this.#elem.style.transition = null; } + /** + * Simulates the wheel moving and updates the wheel rotation accordingly, in + * an animation frame. Automatically stops and snaps to the nearest zodiac + * when the wheel slows down. + */ #paint = () => { if (!this.#animating) { return; @@ -276,16 +385,31 @@ class Wheel { this.#animating.frameId = window.requestAnimationFrame(this.#paint); }; + /** + * Uses a CSS transition to smoothly rotate the wheel to the nearest zodiac. + * + * Note that because we're using CSS transitions, there can be a discrepancy + * between the angle of the wheel that the user sees and `#angle`, which + * stores the rounded angle. To get the angle the user sees, use `#getAngle`. + */ #snap() { this.#elem.style.transition = "transform 0.5s"; this.#setAngle(roundAngle(this.#angle)); } } +/** + * The left wheel. + * @type {Wheel} + */ const leftWheel = new Wheel( document.getElementById("left_wheel_img"), document.getElementById("left_birthday") ); +/** + * The right wheel. + * @type {Wheel} + */ const rightWheel = new Wheel( document.getElementById("right_wheel_img"), document.getElementById("right_birthday"), From 59dd38b07e27183b4e8670abe32e2bc7f09a8655 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 11 Jun 2023 14:18:31 -0700 Subject: [PATCH 07/10] Respect prefers-reduced-motion Fixes #83 --- source/FortuneCookie/fortuneCookie.js | 16 ++++++++++++++++ source/FortuneCookie/style.css | 5 +++++ source/PalmReading/style.css | 5 +++++ source/common/styles.css | 9 +++++++++ 4 files changed, 35 insertions(+) diff --git a/source/FortuneCookie/fortuneCookie.js b/source/FortuneCookie/fortuneCookie.js index 91d053cf..5557905a 100644 --- a/source/FortuneCookie/fortuneCookie.js +++ b/source/FortuneCookie/fortuneCookie.js @@ -51,6 +51,10 @@ function reset(state) { } resetButton.addEventListener("click", () => { + if (prefersReducedMotion()) { + reset("cookie"); + return; + } resetButton.disabled = true; cookieButton.classList.remove("hide-cookie"); cancelButton.parentElement.classList.add("animating"); @@ -164,6 +168,14 @@ function fallNewCookie() { cookieFalling = true; } +/** + * Determines whether the user has `prefers-reduced-motion` enabled. + * @returns {boolean} Whether to reduce motion. + */ +function prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + /** * When the user clicks the button, disables it so they cannot click the button * in quick succession and cause audio issues @@ -173,6 +185,10 @@ document.body.addEventListener("click", async function (event) { if (fortuneButton.disabled) { return; } + if (prefersReducedMotion()) { + reset("fortune"); + return; + } document.body.classList.add("dramatic-mode"); cancelButton.parentElement.classList.add("animating"); cancelButton.parentElement.classList.remove("animating-new-cookie"); diff --git a/source/FortuneCookie/style.css b/source/FortuneCookie/style.css index 7dee566f..bb75d23e 100644 --- a/source/FortuneCookie/style.css +++ b/source/FortuneCookie/style.css @@ -236,6 +236,11 @@ li.audio-dropdown { background-clip: text; animation: reveal-text 1s forwards; } +@media (prefers-reduced-motion) { + .reveal p { + background-position: right; + } +} @keyframes reveal-text { from { diff --git a/source/PalmReading/style.css b/source/PalmReading/style.css index 3b9d1258..e16b72e2 100644 --- a/source/PalmReading/style.css +++ b/source/PalmReading/style.css @@ -293,6 +293,11 @@ body { stroke-width: 1; transition: opacity 0.5s; } +@media (prefers-reduced-motion) { + .ecg { + display: none; + } +} .ecg-active { opacity: 1; transition-delay: 3.5s; diff --git a/source/common/styles.css b/source/common/styles.css index 55683b6a..774f6cce 100644 --- a/source/common/styles.css +++ b/source/common/styles.css @@ -12,6 +12,15 @@ -webkit-overflow-scrolling: touch; } +@media (prefers-reduced-motion) { + *, + :before, + :after { + transition: none !important; + animation: none !important; + } +} + :root { color-scheme: dark; } From 399149f547c18eed9cd570be0f6aae7049b55b27 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 11 Jun 2023 14:27:22 -0700 Subject: [PATCH 08/10] Fix lint error --- source/PalmReading/script.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/source/PalmReading/script.js b/source/PalmReading/script.js index 82cf3000..39016cfb 100644 --- a/source/PalmReading/script.js +++ b/source/PalmReading/script.js @@ -1,6 +1,3 @@ -const fortune = document.getElementById("fortune-paragraph"); -// etc - const heads = [ "Long and straight head line: You possess a logical and analytical mind. You excel in problem-solving and have a practical approach to life.", "Short head line: You tend to be impulsive and prefer to make decisions based on intuition rather than careful analysis. You may have a quick wit and enjoy spontaneous experiences.", From eb58ef014b5b7cfd873872ee86825ffa3924d091 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 11 Jun 2023 14:28:03 -0700 Subject: [PATCH 09/10] Fix documentation link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 12af4f9f..1e49bc33 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Improving the student experience by alleviating decision-making anxiety. Brought to you by [20/20 Visionaries](./admin/team.md) (CSE 110 SP23 Team 20). [![Try it out button](./docs/images/try-button.svg)](https://cse110-sp23-group20.github.io/fortune-teller/source/home-page/) -[![Documentation button](./docs/images/docs-button.svg)](./JSDOCs/) +[![Documentation button](./docs/images/docs-button.svg)](https://cse110-sp23-group20.github.io/fortune-teller/JSDOCs/) [![Team page button](./docs/images/team-page-button.svg)](./admin/team.md) ## Development From 10048e7f78ebbcc6b1d2386f9365d201a94a13de Mon Sep 17 00:00:00 2001 From: "Thomas A. Powell" Date: Sun, 11 Jun 2023 23:41:01 +0000 Subject: [PATCH 10/10] Auto-format --- specs/adrs/fortuneCookieAdr.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/adrs/fortuneCookieAdr.md b/specs/adrs/fortuneCookieAdr.md index 55d319c9..c38a67cf 100644 --- a/specs/adrs/fortuneCookieAdr.md +++ b/specs/adrs/fortuneCookieAdr.md @@ -96,7 +96,7 @@ What if the user is in public places like the library or public coffee shop and ## Decision Drivers - Avoid uncomfortable situations for users -- Provide users with options +- Provide users with options ## Considered Options @@ -106,7 +106,7 @@ What if the user is in public places like the library or public coffee shop and ## Decision Outcome -Chosen option: A checkbox underneath the button. We chose this option because it doesn't interfere with the layout of our website. This will be important for +Chosen option: A checkbox underneath the button. We chose this option because it doesn't interfere with the layout of our website. This will be important for ### Consequences @@ -233,4 +233,4 @@ Chosen option: "Mimicking the motion of cracking a fortune cookie". This makes i - Good, because it is similar to the act of opening an actual fortune cookie. - Good, because it creates an element of surprise. - Bad, because it is too generic. -- Bad, because it can cause confusion. \ No newline at end of file +- Bad, because it can cause confusion.