You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
// atan2 returns angle in radians, convert to degrees
35
+
let theta = Math.atan2(dy, dx) * (180 / Math.PI);
36
+
return theta;
37
+
};
38
+
```
39
+
40
+
`Math.atan2(dy, dx)` is perfect here because it handles all quadrants correctly, returning values from -PI to +PI (-180 to +180 degrees).
41
+
42
+
### Why `Math.atan2`?
43
+
44
+
You might remember SOH CAH TOA from school. To find an angle given x and y, we typically use the tangent function: `tan(θ) = y / x`, so `θ = atan(y / x)`.
45
+
46
+
However, `Math.atan()` has a fatal flaw for UI interaction: it can't distinguish between quadrants.
If we used `atan`, dragging in the bottom-left would behave exactly like dragging in the top-right!
51
+
52
+
`Math.atan2(y, x)` solves this by taking both coordinates separately. It checks the signs of x and y to place the angle in the correct full-circle context (-π to +π radians).
53
+
54
+
We then convert this radian value to degrees:
55
+
`Degrees = Radians * (180 / π)`
56
+
57
+
This gives us a continuous value we can use to map the mouse position directly to the dial's rotation.
58
+
59
+
## The Drag Logic
60
+
61
+
When a user clicks a specific number's hole, we don't just start rotating from 0. We need to know *which* hole they grabbed.
62
+
63
+
Each digit has a "Resting Angle". If the Finger Stop is at 60 degrees, and the holes are spaced 30 degrees apart:
64
+
* Digit 1 is at `60 - 30 = 30` degrees.
65
+
* Digit 2 is at `60 - 60 = 0` degrees.
66
+
* ...and so on.
67
+
68
+
When the user starts dragging, we track the mouse's current angle relative to the center of the dial. The rotation of the dial is then calculated as:
69
+
70
+
`Rotation = CurrentMouseAngle - InitialHoleAngle`
71
+
72
+
### Handling the "Wrap Around"
73
+
74
+
One of the trickiest parts was handling the boundary where angles jump from 180 to -180. For numbers like 9 and 0, the rotation requires dragging past this boundary.
75
+
76
+
If you just subtract the angles, you might get a jump like `179 -> -179`, which looks like a massive reverse rotation. I solved this with a normalization function:
77
+
78
+
```javascript
79
+
const normalizeDiff = (diff) => {
80
+
while (diff <= -180) diff += 360;
81
+
while (diff > 180) diff -= 360;
82
+
return diff;
83
+
};
84
+
```
85
+
86
+
However, simply normalizing isn't enough for the long throws (like dragging '0' all the way around). A normalized angle might look like `-60` degrees, but we actually mean `300` degrees of positive rotation.
87
+
88
+
I added logic to detect this "underflow":
89
+
90
+
```javascript
91
+
// If rotation is negative but adding 360 keeps it within valid range
This ensures that dragging '0' feels continuous, even as it passes the 6 o'clock mark.
98
+
99
+
## State Management vs. Animation
100
+
101
+
Initially, I used standard React state (`useState`) for the rotation. This worked, but `setState` is asynchronous and can feel slightly laggy for high-frequency drag events (60fps).
102
+
103
+
I switched to **Framer Motion's `useMotionValue`**. This allows us to update the rotation value directly without triggering a full React re-render on every pixel of movement. It's buttery smooth.
104
+
105
+
```javascript
106
+
const rotation = useMotionValue(0);
107
+
// ...
108
+
rotation.set(newRotation);
109
+
```
110
+
111
+
When the user releases the dial (`handleEnd`), we need it to spring back to zero. Framer Motion makes this trivial:
112
+
113
+
```javascript
114
+
animate(rotation, 0, {
115
+
type: "spring",
116
+
stiffness: 200,
117
+
damping: 20
118
+
});
119
+
```
120
+
121
+
## The "Call" Logic
122
+
123
+
The drag logic only handles the visual rotation. To actually dial a number, we check the final rotation when the user releases the mouse.
124
+
125
+
If `abs(CurrentRotation - MaxRotation) < Threshold`, we count it as a successful dial.
126
+
127
+
I connected this to a higher-level `RotaryPhonePage` component that maintains the string of dialed numbers.
128
+
129
+
### Easter Eggs
130
+
131
+
Of course, no app is complete without secrets. I hooked up a `handleCall` function that checks specific number patterns:
132
+
* **911**: Triggers a red "Connected" state and unlocks "The Emergency" achievement.
133
+
* **0**: Connects to the Operator.
134
+
* **Others**: Just simulates a call.
135
+
136
+
## Visuals
137
+
138
+
The dial uses Tailwind CSS for styling. The numbers and holes are positioned using `transform: rotate(...) translate(...)`.
139
+
* `rotate(angle)` points the element in the right direction.
140
+
* `translate(radius)` pushes it out from the center.
141
+
* `rotate(-angle)` (on the inner text) keeps the numbers upright!
142
+
143
+
The result is a responsive, interactive, and nostalgic component that was a joy to build. Give it a spin in the **Apps** section!
0 commit comments