Orbital Radio radio
Grab the ball, drag it and fling it — it will be pulled by the gravity of each option and eventually get captured by the nearest black hole.
You can also click an option directly, and the ball will fly over automatically.
Usage Examples
Basic Usage
Can't decide what to drink? Let physics decide for you (ノ>_<)ノ ⌒*
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6 py-10">
<div class="flex justify-center">
<radio-orbital
v-model="selected"
:options="options"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import RadioOrbital from '../radio-orbital.vue'
const { t } = useI18n()
const options = computed(() => [
{ label: t('bubbleTea'), value: 'bubble-tea' },
{ label: t('blackTea'), value: 'black-tea' },
{ label: t('cocoa'), value: 'cocoa' },
{ label: t('orangeJuice'), value: 'orange-juice' },
])
const selected = ref('bubble-tea')
</script>How It Works
Each option acts like a black hole in space, exerting gravitational pull on the ball. After being flung, the ball's acceleration is calculated using Newton's law of universal gravitation, while a damping coefficient simulates air resistance to gradually slow it down.
When the ball gets close enough to a black hole and its speed drops below a threshold, it gets captured, triggering the selection event.
Here's how it works step by step:
- Pointer Events track the drag trajectory, and a sliding time window computes the velocity at the moment of release.
- After release, a physics simulation kicks in — each frame calculates the gravitational acceleration from all black holes.
- Damping gradually slows the ball down; hitting the viewport boundary causes a bounce.
- When the ball enters capture range with low enough speed, it gets sucked into the black hole and the selection is made.
Gravitational Softening
In real N-body simulations, when two objects get extremely close, the denominator in the gravity formula approaches zero, causing acceleration to skyrocket toward infinity.
The softening parameter adds a small value to the denominator to prevent this numerical explosion. (. ❛ ᴗ ❛.)
More Accurate Simulation
After sharing this component, someone suggested using Runge-Kutta instead of the Euler method for solving the equations.
After looking into it, I learned a lot! ヾ(◍'౪`◍)ノ゙
With the Euler method, the ball would exhibit unnatural bouncing when moving between black holes. After switching to RK4, the trajectory became noticeably smoother — no need to artificially lower gravity or increase damping to mask numerical errors.
Never thought making silly little components could be so educational. Thanks to everyone for the feedback! (*´∀`)~♥
Before diving into the scary-looking formulas, let's compare with an intuitive trajectory visualization.
Using identical initial conditions and the same black hole configuration, observe the difference in trajectories.
The conclusions are clear:
- Euler's ball exhibits abnormal acceleration due to numerical errors and oscillates wildly near zero points — the trajectory is extremely unstable.
- Euler Sub-step splits each frame into 4 sub-steps, reducing local error and producing a noticeably more stable trajectory than plain Euler — but it is still first-order.
- RK4 maintains a smooth and stable orbit.
Now let's break down the differences between Runge-Kutta and Euler.
From Euler to Runge-Kutta
At its core, a physics simulation is an ordinary differential equation (ODE) initial value problem: given the ball's position and velocity at a certain moment, derive the next state using the laws of mechanics.
Computers can't handle continuous time, so we slice time into small intervals dt (each frame's duration) and use numerical integration to approximate the real trajectory.
The core equations can be written as:
Where
Euler Method (First Order)
The most intuitive approach — use the current slope and step forward directly:
Translated to code:
velocity += acceleration * dt
position += velocity * dtThe Euler method's local truncation error is dt is very small.
Why Euler Fails Here
The gravity formula is:
As the ball approaches a black hole,
It looks as if the ball gains extra acceleration out of nowhere, appearing to be shot to the other side of the black hole.
The result is a cycle of being pulled back, shot out, pulled back, shot out again — repeated bouncing.
This is known as the Slingshot Effect, a classic symptom of numerical instability.
Imagine drawing a sharp curve, but you're only allowed to glance at the direction once at the starting point, then walk a big step with your eyes closed — you'll definitely overshoot the curve. =͟͟͞͞( •̀д•́)
Euler Sub-stepping
The source of Euler's error is intuitive: the larger the dt, the more each step drifts.
The most direct fix is to split each frame's dt into
Translated to code:
const subStepCount = 4
const subDt = dt / subStepCount
for (let i = 0; i < subStepCount; i++) {
const { ax, ay } = computeAcceleration(position.x, position.y)
velocity.x += ax * subDt
velocity.y += ay * subDt
position.x += velocity.x * subDt
position.y += velocity.y * subDt
}The force field is resampled at each sub-step, so errors shrink significantly near strongly curved gravity wells.
The global error is still
Sub-stepping is not the same as a higher-order method
Even with
RK4 achieves
Runge-Kutta 4th Order (RK4)
RK4's strategy goes beyond just looking at the starting point — it takes 4 samples within a single dt, considering how the force field changes across the entire interval.
The steps:
k1: Compute acceleration at the starting point
k2: Take a half step using k1, compute acceleration at the midpoint
k3: Take another half step using k2 (a different estimate), compute again
k4: Take a full step using k3, compute acceleration at the endpoint
Finally, the weighted average:
The weights
RK4's local truncation error is
Intuitive Comparison
| Euler | Euler Sub-step | RK4 | |
|---|---|---|---|
| Analogy | Glance at the direction once, walk a big step blind | Split the big step into four small ones, look each time | Walk a bit, look around, repeat four times, then average out |
| Samples per frame | 1 | 4 | |
| Global error | |||
| Sharp turn behavior | Easily overshoots the curve | More stable than Euler, but still first-order | Stays close to the curve |
The trade-off is computing acceleration 4 times per frame (iterating through all black holes 4 times), but the number of black holes equals the number of options — usually no more than 10 — which is absolutely no problem for modern browsers. ( •̀ ω •́ )✧
Source Code
API
Props
interface RadioOption {
label: string;
value: string;
}
interface Props {
/** 目前選取的值 */
modelValue?: string;
/** 選項列表 */
options?: RadioOption[];
/**
* 球的直徑(px)
* @default 12
*/
ballSize?: number;
/**
* 重力強度
* @default 5_000_000
*/
gravity?: number;
/**
* 阻尼係數(0-1)。數值越小球減速越快,會很快停下來;數值越大球滑行距離越長,減速越慢
* @default 0.9
*/
damping?: number;
/**
* 捕捉距離(px)。球進入此範圍且速度夠低時會被吸入。數值越大越容易被捕捉;數值越小需要更精確地靠近黑洞
* @default 14
*/
captureDistance?: number;
/**
* 捕捉速度閾值(px/s)。球速度低於此值才會被捕捉。數值越大球在較快速度時也能被吸入;數值越小球必須幾乎靜止才會被捕捉
* @default 100
*/
captureSpeed?: number;
/**
* 重力軟化參數,防止球靠近黑洞時加速度爆炸。數值越大重力變化越平滑,球不會突然加速;數值越小球靠近黑洞時會急劇加速
* @default 12
*/
softening?: number;
/**
* 點選黑洞時球移動過去的動畫時長(ms)
* @default 350
*/
moveDuration?: number;
/**
* 主色調,用於球顏色與選中時的邊框色。接受任何 CSS 合法顏色值
* @default '#34c6eb'
*/
color?: string;
}Emits
interface Emits {
'update:modelValue': [value: string];
}