import { wait } from "../utils.js";
import {
angleDiff,
determineDateRangeLeft,
getMappingLeft,
roundAngle,
textGenerator,
} from "./zodiac-angles.js";
// Get all necessary document objects
const button = document.getElementById("find-out");
const howTo = document.getElementById("how-to");
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 - 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 - 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 {
/**
* 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;
/**
* 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;
this.#dateInput = dateInput;
this.#angleOffset = angleOffset;
// add the necessary event listeners for the wheel
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);
document.addEventListener("keydown", this.#handleKeydown);
}
/**
* 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));
}
/**
* 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}`);
}
this.#angle = angle;
// Apply the rotation transform to the wheel element
this.#elem.style.transform = `rotate(${this.#angle}deg)`;
this.#dateInput.value = window.i18n.i18n(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(
/^matrix\((-?\d+(?:\.\d+)?(?:e-?\d+)?), (-?\d+(?:\.\d+)?(?:e-?\d+)?)/
);
if (match) {
const cosine = +match[1];
const sine = +match[2];
return Math.atan2(sine, cosine) * (180 / Math.PI);
} else {
return this.#angle;
}
}
/**
* 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.
*/
#getMouseAngle(event) {
const center = this.#getCenter();
return (
// y is first
Math.atan2(event.clientY - center.y, event.clientX - center.x) *
(180 / Math.PI)
);
}
/**
* 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) {
const wheelAngle = this.#getAngle();
const mouseAngle = this.#getMouseAngle(event);
this.#pointer = {
pointerId: event.pointerId,
initWheelAngle: wheelAngle,
initMouseAngle: mouseAngle,
lastMouseAngle2: mouseAngle,
lastTime2: Date.now(),
lastMouseAngle1: mouseAngle,
lastTime1: Date.now(),
};
this.#elem.setPointerCapture(event.pointerId);
// Set angle again in case it was interrupted mid-transition
this.#setAngle(wheelAngle);
this.#stopMomentum();
}
};
/**
* Event handler for the `pointermove` event.
* @param {PointerEvent} event - Event object.
*/
#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
);
}
};
/**
* 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) => {
if (this.#pointer?.pointerId === event.pointerId) {
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;
}
};
/**
* Rotates the wheel based on the mouse wheel event.
* @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) * Math.sign(center.x - event.clientX);
// Update the rotation angle based on the scrolling direction
this.#setAngle(wheelAngle + direction * 2);
this.#stopMomentum();
this.#wheelTimeout = setTimeout(() => {
this.#wheelTimeout = null;
this.#snap();
}, 500);
// Prevent the default scrolling behavior
event.preventDefault();
};
#handleKeydown = (event) => {
const wheelAngle = this.#getAngle();
// Update the rotation angle based on the scrolling direction
if (this.#elem == document.getElementById("left-wheel-img")) {
if (event.key == "ArrowLeft") {
this.#setAngle(wheelAngle - 2);
}
if (event.key == "ArrowRight") {
this.#setAngle(wheelAngle + 2);
}
}
if (this.#elem == document.getElementById("right-wheel-img")) {
if (event.key == "ArrowUp") {
this.#setAngle(wheelAngle + 2);
}
if (event.key == "ArrowDown") {
this.#setAngle(wheelAngle - 2);
}
}
this.#stopMomentum();
this.#wheelTimeout = setTimeout(() => {
this.#wheelTimeout = null;
this.#snap();
}, 500);
};
/**
* 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) {
this.#animating = {
frameId: 0,
lastTime: Date.now(),
angleVel,
};
this.#paint();
}
}
/**
* 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);
this.#animating = null;
}
if (this.#wheelTimeout) {
clearTimeout(this.#wheelTimeout);
this.#wheelTimeout = null;
}
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;
}
const now = Date.now();
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);
};
/**
* 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"),
180
);
// add all the necessary event listeners for the buttons
button.addEventListener("click", displayResults);
howTo.addEventListener("click", () => {
help.parentElement.classList.add("open");
});
document.addEventListener("click", (event) => {
if (
event.target.classList.contains("popup-wrapper") ||
event.target.closest(".close-button")
) {
const popupWrapper = event.target.closest(".popup-wrapper");
popupWrapper.classList.remove("open");
if (popupWrapper === popup.parentElement) {
document.body.classList.remove("remove-wheels");
}
}
});
/**
* Displays the results of the pairing and animates the UI elements.
*/
async function displayResults() {
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 =
window.i18n.i18n(left) +
window.i18n.i18n("romantic-and") +
window.i18n.i18n(right);
const pairingText = popup.querySelector("#pairing-text");
pairingText.textContent = window.i18n.i18n(
textGenerator(left.split("-")[1], right.split("-")[1])
);
/**
* Displays the popup with the pairing information after a delay.
*/
await wait(200);
popup.parentElement.classList.add("open");
}