|
| 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! |
0 commit comments