Skip to content

Commit aa5cb9c

Browse files
committed
blog: rotary phone.
1 parent f1d9089 commit aa5cb9c

File tree

6 files changed

+176
-6
lines changed

6 files changed

+176
-6
lines changed
36.6 KB
Loading
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
In a world of touchscreens and haptic feedback, there's something deeply satisfying about the mechanical click-whirrr of a rotary phone.
2+
I recently built a digital version of this retro interface for Fezcodex, and I want to take you through the engineering journey—from the
3+
trigonometry of the dial to the state management of the call logic.
4+
5+
## The Challenge
6+
7+
Building a rotary phone for the web isn't just about displaying an image. It's about capturing the *feel* of the interaction. You need to:
8+
1. Draw a dial with holes.
9+
2. Detect user input (mouse or touch).
10+
3. Calculate the rotation based on the pointer's position.
11+
4. "Drag" the dial realistically.
12+
5. Snap back when released.
13+
6. Register the dialed number only if the user drags far enough.
14+
15+
## Anatomy of the Dial
16+
17+
I broke the `RotaryDial` component into a few key layers, stacked using CSS absolute positioning:
18+
19+
1. **The Backplate**: This is static. It sits at the bottom and holds the numbers (1, 2, 3... 0) in their correct positions.
20+
2. **The Rotating Disk**: This sits on top of the backplate. It rotates based on user interaction. It contains the "holes".
21+
3. **The Finger Stop**: A static hook at the bottom right (approx 4 o'clock position) that physically stops the dial on a real phone.
22+
23+
### The Trigonometry of Angles
24+
25+
The core of this component is converting a mouse position (x, y) into an angle (theta).
26+
27+
```javascript
28+
const getAngle = (event, center) => {
29+
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
30+
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
31+
32+
const dx = clientX - center.x;
33+
const dy = clientY - center.y;
34+
// 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.
47+
* Quadrant 1: x=1, y=1 -> `atan(1/1)` = 45°
48+
* Quadrant 3: x=-1, y=-1 -> `atan(-1/-1)` = `atan(1)` = 45°
49+
50+
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
92+
if (newRotation < 0 && (newRotation + 360) <= maxRot + 30) {
93+
newRotation += 360;
94+
}
95+
```
96+
97+
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!

public/posts/posts.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
[
2+
{
3+
"slug": "building-a-digital-rotary-phone",
4+
"title": "Building a Digital Rotary Phone",
5+
"date": "2025-12-02",
6+
"updated": "2025-12-02",
7+
"description": "A deep dive into the engineering behind the interactive Rotary Phone app: trigonometry, Framer Motion, and React state management.",
8+
"tags": ["react", "framer-motion", "math", "interactive", "frontend", "project"],
9+
"category": "dev",
10+
"filename": "building-a-digital-rotary-phone.txt",
11+
"authors": ["fezcode"],
12+
"image": "/images/defaults/mike-meyers--haAxbjiHds-unsplash.jpg"
13+
},
214
{
315
"slug": "nocturnote",
416
"title": "Nocturnote: A Sleek and Modern Text Editor",
@@ -599,4 +611,4 @@
599611
"authors": ["fezcode"],
600612
"image": "/images/defaults/sina-salehian-HqmTUJD73mM-unsplash.jpg"
601613
}
602-
]
614+
]

src/components/Toast.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,20 @@ const Toast = ({
3131
' bg-toast-error-background border-toast-error-border hover:bg-toast-error-background/90';
3232
const goldStyle =
3333
' bg-toast-gold-background border-toast-gold-border hover:bg-toast-gold-background/90';
34-
34+
const technoStyle =
35+
' bg-toast-techno-background border-toast-techno-border hover:bg-toast-techno-background/90';
36+
// Techno Style
37+
const technoTextStyle = 'text-toast-techno-color';
38+
const technoHRStyle = 'border-toast-techno-color';
39+
// Toast Style
3540
let toastStyle = successStyle;
41+
// Toast Text Style
42+
let textStyle = 'text-red-100'
43+
// Toast HR Style
44+
let hrStyle = 'border-red-200'
3645
if (type === 'error') toastStyle = errorStyle;
3746
if (type === 'gold') toastStyle = goldStyle;
47+
if (type === 'techno') { toastStyle = technoStyle; textStyle = technoTextStyle; hrStyle = technoHRStyle; }
3848

3949
return (
4050
<motion.div
@@ -47,13 +57,13 @@ const Toast = ({
4757
<div className="flex flex-col text-sm group w-max flex-grow">
4858
<div className="flex items-center gap-2">
4959
{icon && <span className="text-xl text-red-100">{icon}</span>}
50-
<span className="text-base text-red-100">{title}</span>
60+
<span className={`text-base ${textStyle}`}>{title}</span>
5161
</div>
5262
<motion.hr
5363
initial={{ width: 0 }}
5464
animate={{ width: '100%' }}
5565
transition={{ duration: duration / 1000 }}
56-
className="mt-1 mb-1 min-w-max mr-5 border-red-200"
66+
className={`mt-1 mb-1 min-w-max mr-5 ${hrStyle}`}
5767
/>
5868
<span className="text-sm text-stone-200">{message}</span>
5969
{links && links.length > 0 && (

src/config/colors.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ module.exports = {
8888
'toast-gold-hover-background': 'rgba(122, 100, 49, 0.8)',
8989
'toast-gold-border': '#68562b',
9090

91+
// Toast techno colors
92+
'toast-techno-background': 'rgba(49,67,99,0.8)',
93+
'toast-techno-hover-background': 'rgba(63,88,126,0.8)',
94+
'toast-techno-border': '#3e5973',
95+
'toast-techno-color': '#22d0eb',
9196
// Code Theme Colors (from src/utils/customTheme.js)
9297
codeTheme: {
9398
'text-default': '#d1d5db',

src/pages/BlogPostPage.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,8 @@ const BlogPostPage = () => {
181181
const handleCopy = () => {
182182
const textToCopy = String(children);
183183
navigator.clipboard.writeText(textToCopy).then(
184-
() => addToast({title: 'Success', message: 'Copied to clipboard!', duration: 3000}),
185-
() => addToast({title: 'Error', message: 'Failed to copy!', duration: 3000})
184+
() => addToast({title: 'Success', message: 'Copied to clipboard!', duration: 3000, type:'techno'}),
185+
() => addToast({title: 'Error', message: 'Failed to copy!', duration: 3000, type:'error'})
186186
);
187187
};
188188

0 commit comments

Comments
 (0)