В процессе разработки WEBCOS я захотел сделать интерактивную систему планет. Причём не просто вращение по кругу, а возможность перетаскивать планеты мышью прямо по орбите. Код получился не самым простым, но и не самым сложным, при этом новой информации можно было узнать довольно много. И сейчас я хочу поделиться с вами тем, как:
создавать анимации
сделать drag&drop
работать с requestAnimationFrame
сделать одинаковую скорость анимации на любых устройствах (достигнем плавности)
создавать инерцию
работать с “невероятно сложными” формулами и как самому их создавать
сделать реэкспорты и что это такое
Всё было сделано на React, но переделать под Next.js не составит труда (По крайней мере можете зайти в открытый репозиторий нашего проекта и посмотреть код сами)
Чтобы вы могли посмотреть результат, соориентироваться и избежать проблем при написании кода или прочитать комментарии и разобраться самим, высылаю ссылку на проект:
https://stackblitz.com/edit/vitejs-vite-gc8qzgym?file=package.json
(Советую смотреть статью и одновременно читать комментарии к коду, в статье я объясняю подробнее и наверно понятнее)
Осторожно, может быть проблема с Vite 8 + Rolldown WASM + StackBlitz WebContainer
Для решения понижаем версию Vite, в package.json прописываем:
{
"vite": "^7.3.5",
"@vitejs/plugin-react": "^5.1.0"
}Сохраняем и затем пишем:
rm -rf node_modules package-lock.json
pnpm install Вот кстати и сам результат:
Начало
Итак, будет прям с полного нуля, для тех кому это неинтересно можете скипнуть и начать уже с создания архитектуры и реэкспортов. Начнём с создания проекта, выбирайте, что вам удобно. Я буду делать на React + Typescript (Сборщик Vite):
npm create vite@latest Чистим новый проект от мусора
Далее уберём всё лишнее:
Почистим App.tsx и сделаем единственный div, классом intro
function App() {
return (
<div className="intro">
</div>
)
}
export default AppУдалим файлы с картинками в папке assets, App.css
Почистим index.css и добавим стили
body {
background-color: #000; /* Задний фон */
}
/* Делаем, чтобы планеты все были по центру */
.intro {
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s linear;
flex-direction: column;
}Создаём структуру проекта
Теперь добавим немного структуры в папке src:
папка assets (внутри папки icons, images)
папка components
папка utils
Закинем изображения с орбитами и планетами (скачать)
Добавляем Реэкспорты для изображений
Реэкспорты - это объединение экспорта и импорта в одном файле, он нужен для того, чтобы в других файлах сделать красивый импорт (короткий и читаемый), т.е не с 15 файлов, а с одного. Обычно их добавляют на этапе рефакторинга, но мы сделаем сразу, чтобы потом не возвращаться к этому.
Реэкспорты изображений: создадим в папке assets файл index.ts:
export { default as PlanetIcon1 } from './icons/planet-1.svg';
export { default as PlanetIcon2 } from './icons/planet-2.svg';
export { default as PlanetIcon3 } from './icons/planet-3.svg';
export { default as PlanetIcon4 } from './icons/planet-4.svg';
export { default as PlanetIcon5 } from './icons/planet-5.svg';
export { default as PlanetIcon6 } from './icons/planet-6.svg';
export { default as PlanetIcon7 } from './icons/planet-7.svg';
export { default as PlanetIcon8 } from './icons/planet-8.svg';
export { default as OrbitImage } from './images/orbit2.png';
export { default as ThemeMoonImage } from './images/theme-moon.png';Посмотрим же, как это работает.
Делаем настройки для планет и орбит
Чтобы не хранить настройки каждой планеты внутри компонента, вынесем их в отдельный массив.
Так мы сможем добавлять новые планеты буквально одной записью. Сделаем файл с настройками планет в папке utils, создаём файл planets.ts. И в самом начале как раз видим результат нашей работы с реэкспортами.
Вместо:
import PlanetIcon1 from '../assets/icons/planet-1.svg';
import PlanetIcon1 from '../assets/icons/planet-2.svg';
import PlanetIcon1 from '../assets/icons/planet-3.svg';
...Мы получили единый импорт (да возможно кол-во строк мы не сократили, но при этом это выглядит в разы красивее и лучше). Так же реэкспорты используются для инкапсуляции (чтобы открывать остальному приложению только то, что нужно и скрыть кучу вспомогательных файлов, например в архитектуре FSD) . Про сами настройки я расскажу на этапе создания компонента Planet.tsx:
import {
PlanetIcon1,
PlanetIcon2,
PlanetIcon3,
PlanetIcon4,
PlanetIcon5,
PlanetIcon6,
PlanetIcon7,
PlanetIcon8,
} from '../assets';
interface Planet {
id: string;
direction: 'left' | 'right';
marginRight: number;
planetSrc: string;
planetHeight: number;
planetWidth: number;
height: number;
width: number;
speed: number;
}
export const planets: Planet[] = [
{
id: '1',
direction: 'left',
height: 1200,
width: 1200,
planetHeight: 28,
planetWidth: 28,
marginRight: -13,
planetSrc: PlanetIcon1,
speed: 0.18,
},
{
id: '2',
direction: 'right',
height: 1050,
width: 1050,
planetHeight: 52,
planetWidth: 52,
marginRight: -25,
planetSrc: PlanetIcon2,
speed: 0.14,
},
{
id: '3',
direction: 'left',
height: 940,
width: 940,
planetHeight: 120,
planetWidth: 120,
marginRight: -60,
planetSrc: PlanetIcon3,
speed: 0.1,
},
{
id: '4',
direction: 'right',
height: 810,
width: 810,
planetHeight: 140,
planetWidth: 140,
marginRight: -60,
planetSrc: PlanetIcon4,
speed: 0.08,
},
{
id: '5',
direction: 'left',
height: 640,
width: 640,
planetHeight: 110,
planetWidth: 110,
marginRight: -50,
planetSrc: PlanetIcon5,
speed: 0.065,
},
{
id: '6',
direction: 'right',
height: 500,
width: 500,
planetHeight: 90,
planetWidth: 100,
marginRight: -40,
planetSrc: PlanetIcon6,
speed: 0.05,
},
{
id: '7',
direction: 'left',
height: 360,
width: 360,
planetHeight: 36,
planetWidth: 36,
marginRight: -15,
planetSrc: PlanetIcon7,
speed: 0.035,
},
{
id: '8',
direction: 'right',
height: 260,
width: 260,
planetHeight: 24,
planetWidth: 24,
marginRight: -13,
planetSrc: PlanetIcon8,
speed: 0.025,
},
];Создаём компонент с системой планет
Теперь начнём делать компоненты. В папке components создаём ещё одну PlanetSystem и туда добавляем файл PlanetSystem.tsx. Тут ничего трудного, просто отображение по центру луны, а вокруг него планеты с орбитами:
// PlanetSystem.tsx
import styles from './PlanetSystem.module.css';
import { ThemeMoonImage } from '../../assets';
import { planets } from '../../utils/planets';
import Planet from '../Planet/Planet.tsx';
export const PlanetSystem = () => {
return (
<div className={styles.wrapper}>
<div className={styles.circleWrapper}>
{planets.map((planet) => (
<Planet
key={planet.id}
direction={planet.direction}
marginRight={planet.marginRight}
planet={planet.planetSrc}
planetHeight={planet.planetHeight}
planetWidth={planet.planetWidth}
height={planet.height}
width={planet.width}
speed={planet.speed}
/>
))}
<img alt="Moon" className={styles.themeImage} src={ThemeMoonImage} />
</div>
</div>
);
};А так же стили PlanetSystem.module.css:
.wrapper {
position: relative;
width: min(1200px, 100vw);
height: min(1200px, 100vw);
display: flex;
justify-content: center;
align-items: center;
overflow: visible;
}
.circleWrapper {
position: relative;
width: 1200px;
height: 1200px;
display: flex;
justify-content: center;
align-items: center;
transform-origin: center center;
}
.themeImage {
position: absolute;
top: 50%;
left: 50%;
width: 150px;
height: 150px;
transform: translate(-50%, -50%);
z-index: 100;
object-fit: contain;
}
/* планшеты */
@media (max-width: 1200px) {
.circleWrapper {
transform: scale(0.6);
}
}
/* маленькие планшеты */
@media (max-width: 930px) {
.circleWrapper {
transform: scale(0.4);
}
.themeImage {
width: 100px;
height: 100px;
}
}
/* телефоны */
@media (max-width: 630px) {
.circleWrapper {
transform: scale(0.25);
}
.themeImage {
width: 60px;
height: 60px;
}
}
/* совсем маленькие */
@media (max-width: 420px) {
.circleWrapper {
transform: scale(0.18);
}
.themeImage {
width: 40px;
height: 40px;
}
}Из интересного:
для более маленьких экранов мы просто будем уменьшать нашу систему через scale
для того, чтобы луна была ровно по центру используем знаменитый приём:
position: absolute:
top: 50%;
left: 50%;
transform: translate(-50%, -50%);Создаём планету с орбитой
Переходим к главному, компонент планеты Planet.tsx.
Как устроен компонент Planet
Вся логика вращения, перетаскивания и визуальных эффектов находится внутри одного компонента.
Он отвечает за:
отображение орбиты;
отображение планеты;
автоматическое вращение;
drag-and-drop;
инерцию;
Код может показаться большим, но на самом деле его можно разделить на три независимые части:
анимация вращения;
обработка перетаскивания;
визуальные эффекты.
Я сразу выведу код, с комментариями, сейчас будем разбираться по блокам
import styles from './Planet.module.css';
import React, { useEffect, useRef, useState } from 'react';
// Картинка орбиты
import { OrbitImage } from '../../assets';
// Пропсы планеты
interface IPlanet {
width: number; // диаметр орбиты
height: number; // диаметр орбиты
planet: string; // изображение планеты
speed: number; // скорость вращения
planetHeight: number; // высота планеты
planetWidth: number; // ширина планеты
marginRight: number; // смещение планеты от края орбиты
direction: 'right' | 'left'; // направление вращения
}
export default function Planet({
height,
width,
speed,
planet,
planetWidth,
planetHeight,
marginRight,
direction,
}: IPlanet) {
// ссылка на элемент, который вращается
const pickerRef = useRef<HTMLDivElement | null>(null);
// ссылка на саму орбиту
const orbitRef = useRef<HTMLDivElement | null>(null);
// нужно только для смены css-класса при перетаскивании
const [dragging, setDragging] = useState(false);
// id requestAnimationFrame
// нужен для остановки анимации при размонтировании компонента
const animationRef = useRef<number>(0);
// текущий угол поворота планеты
// начинаем со случайного положения
const angleRef = useRef(Math.random() * 360);
// сейчас планета перетаскивается мышью?
const isDraggingRef = useRef(false);
// скорость после броска мышкой
// используется для эффекта инерции
const velocityRef = useRef(0);
/**
* Вычисляет угол между центром орбиты
* и текущей позицией мыши.
* Используется во время drag'n'drop.
* x, y - координаты мыши
*/
const getAngle = (x: number, y: number) => {
if (!orbitRef.current) return 0;
// элемент орбиты
const rect = orbitRef.current.getBoundingClientRect();
// центр орбиты
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// вектор от центра к мыши
const deltaX = x - centerX;
const deltaY = y - centerY;
// переводим радианы в градусы
/*
* Что такое Math.atan2 - это угол на который нужно повернуть
* относительно базовой горизонтальной оси координат
* для того, чтобы элемент смотрел на курсор
* Более подробно в статье
* */
return (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
};
/**
* Главная анимация вращения.
*
* Работает через requestAnimationFrame.
*/
useEffect(() => {
// количество миллисекунд с момента начала загрузки страницы
// (но будет в итоге как время с прошлого кадра)
let lastTime = performance.now();
// Анимирование
const animate = (time: number) => {
// если вкладка скрыта,
// просто ждём следующий кадр
if (document.hidden) {
animationRef.current = requestAnimationFrame(animate);
return;
}
// сколько миллисекунд прошло с прошлого кадра
const delta = time - lastTime;
// обновили на новый кадр
lastTime = time;
if (pickerRef.current) {
// если пользователь НЕ тащит планету
if (!isDraggingRef.current) {
// определяем направление вращения
// (т.е просто будем умножать на 1 или на -1)
const directionMultiplier =
direction === 'left' ? 1 : -1;
// вращаем планету (меняем угол)
/*
* Поймём формулу:
* directionMultiplier - просто направление движения
* speed - угол на который мы должны повернуть за кадр (при 60 fps)
* delta / 16.67 - масштабируем скорость в зависимости от производительности
* То есть к примеру 60 fps: 16.67/16.67 = 1 (За кадр повернем ровно на множитель speed)
* 30 fps: 33.33/16.67 = 2 (За один кадр мы повернем в 2 раза больше чем множитель speed)
* 120 fps: 8.33/16.67 = 0.5 (За один кадр мы повернем в 2 раза меньше чем множитель speed)
* В чём суть?
* За одну секунду реального времени объект повернется на абсолютно одинаковый угол, что на мощном пк,
* что и на слабом пк
* На слабом пк скорость изменения в 2 раза больше ЗА ОДИН КАДР, но самих КАДРОВ тоже меньше в 2 раза:
* по итогу получаем абсолютно одинаковое поведение как на мощном, так и на слабом пк за секунду времени
* 16.67 - просто длительность кадра при эталонных 60 FPS
* */
angleRef.current +=
directionMultiplier *
speed * // speed - угол на который объект должен повернуться за кадр
(delta / 16.67);
// Если инерция стала слишком мала, зануляем её, чтобы не тратить ресурсы
if (Math.abs(velocityRef.current) < 0.001) {
velocityRef.current = 0;
}
// добавляем инерцию
angleRef.current += velocityRef.current;
// постепенно гасим скорость
velocityRef.current *= 0.96;
}
// вращаем всю орбиту
pickerRef.current.style.transform =
rotate(${angleRef.current}deg);
}
// переходим к следующему кадру
animationRef.current =
requestAnimationFrame(animate);
};
// запуск анимации
animationRef.current =
requestAnimationFrame(animate);
// очистка при удалении компонента
return () => {
cancelAnimationFrame(animationRef.current);
};
}, [direction, speed]);
/**
* Обработка drag'n'drop
*/
useEffect(() => {
// функция, которая будет работать при изменении позиции мыши
const handleMouseMove = (e: MouseEvent) => {
// проверяем захвачен ли элемент
if (
!isDraggingRef.current ||
!pickerRef.current
) {
return;
}
// новый угол относительно центра орбиты
const angle =
getAngle(e.clientX, e.clientY);
// вычисляем скорость броска
velocityRef.current =
(angle - angleRef.current) * 0.12; // каждый кадр будем преодолевать только 12% от оставшегося расстояния (для плавности)
// обновляем угол
angleRef.current = angle;
// сразу отображаем новое положение
pickerRef.current.style.transform =
rotate(${angle}deg);
};
const handleMouseUp = () => {
// прекращаем перетаскивание
isDraggingRef.current = false;
setDragging(false);
document.body.style.cursor = '';
};
window.addEventListener(
'mousemove',
handleMouseMove
);
window.addEventListener(
'mouseup',
handleMouseUp
);
return () => {
window.removeEventListener(
'mousemove',
handleMouseMove
);
window.removeEventListener(
'mouseup',
handleMouseUp
);
};
}, []);
/**
* Начало перетаскивания
*/
const handleMouseDown = (
e: React.MouseEvent
) => {
e.preventDefault();
isDraggingRef.current = true;
setDragging(true);
// сбрасываем прошлую инерцию
velocityRef.current = 0;
document.body.style.cursor =
'grabbing';
};
return (
<div
ref={orbitRef}
className={styles.orbitInner}
style={{
width: ${width}px,
height: ${height}px,
backgroundImage: url(${OrbitImage}),
}}
>
{/* Вращающаяся часть орбиты */}
<div
ref={pickerRef}
className={`${styles.picker} ${
dragging ? styles.dragging : ''
}`}
>
{/* Планета */}
<div
onMouseDown={handleMouseDown}
className={styles.innerOrbitCircle}
style={{
width: ${planetWidth}px,
height: ${planetHeight}px,
marginRight: ${marginRight}px,
backgroundImage: url(${planet}),
}}
/>
</div>
</div>
);
} Начнём с тех самых настроек:
interface IPlanet {
width: number // диаметр орбиты
height: number // диаметр орбиты
planet: string // изображение планеты
speed: number // скорость вращения
planetHeight: number // высота планеты
planetWidth: number // ширина планеты
marginRight: number // смещение планеты от края орбиты
direction: 'right' | 'left' // направление вращения
}Есть параметры, которые в целом и так понятны, но к примеру, есть скорость.
Объясню сейчас немного, позже станет ещё понятней.
speed - кол-во градусов на которое подвинется планета за кадр
marginRight - кол-во пикселей на которое нужно сместить планету, чтобы она встала на свою орбиту нормально (делаем методом подбора) )
direction - как именно будет двигаться планета (против часовой или по часовой)
Инициализация
Идём дальше.
const animationRef = useRef(0);<number>(0);В animationRef мы будем хранить requestAnimationFrame, чтобы он не пересоздавался между ререндерами. Пока что поставим затычку в виде 0. В целом тоже самое и с другими useRef, по типу
const velocityRef = useRef(0);и
const angleRef = useRef(Math.random() * 360);Главная анимация:
perfomance.now() нам просто выдаст кол-во миллисекунд с момента начала загрузки страницы.
И представляете, в функции animate мы принимаем time - время с момента начала загрузки.
requestAnimationFrame - тоже возвращает время с момента начала загрузки автоматически, когда мы будем вызывать её через requestAnimationFrame(animate)
поэтому буквально
const delta = time - lastTime;это: [время с начала загрузки страницы в новом кадре] - [время с начала загрузки страницы в старом кадре]
Отсюда получаем, что у нас delta - это просто время прошедшее между старым и новым кадром.
К примеру:
у 60 FPS - время между кадрами это 1000 мс / 60 кадров = 16.67 мс на кадр (Ну потому что FPS - кадров в секунду, 1000 мс = 1 сек)
Ну и следующая сложная формула становится чуть понятней
angleRef.current +=
directionMultiplier *
speed *
(delta / 16.67);То есть по сути есть directionMultiplier, он просто будет умножать либо на -1, либо на 1, тем самым задавая направление.
Есть speed, который мы ранее обозначили, как кол-во градусов на кадр, как раз из-за этой формулы.
И есть (delta/16.67) - что это? Ну я уже давал пример, что 16.67 это кол-во мс, которое проходит между новым и старым кадром при 60 FPS, а delta - это как раз тоже время между новым и старым кадром, просто не факт, что для 60 FPS, так как устройства разные бывают (как мощные, так и слабые). Нам это нужно, чтобы за одну секунду реального времени объект повернулся на абсолютно одинаковый угол, как на мощном, так и на слабом девайсе.
Как это работает?
То есть к примеру 60 fps: 16.67/16.67 = 1 (За кадр повернем ровно на множитель speed)
30 fps: 33.33/16.67 = 2 (За один кадр мы повернем в 2 раза больше чем множитель speed)
120 fps: 8.33/16.67 = 0.5 (За один кадр мы повернем в 2 раза меньше чем множитель speed)
На слабом пк скорость изменения в 2 раза больше ЗА ОДИН КАДР, но самих КАДРОВ тоже меньше в 2 раза:
по итогу получаем абсолютно одинаковое поведение как на мощном, так и на слабом пк за СЕКУНДУ времени.
Вот и сложная формула, стала в супер простой и понятной!
Инерцию пока что просто прибавим, а само вычисление позже, так как её нужно высчитывать при drag&drop'е. Но так же тут нам нужно инерцию занулить при очень маленьких значениях (просто, чтобы не нагружать пк линшними вычислениями) и умножать на 0.96 (можно самим подкрутить), чтобы скорость на каждом кадре постепенно гасилась.
Drag&Drop:
Переходим дальше к взаимодействию с планетами! Здесь drag&drop будет реализовано скорее как в библиотеках с анимациями, так как Drag & Drop API немного не удобен из-за ghost image'а, ограничений и менее плавному перетаскиванию.
Первое handleMouseMove - это функция, которая будет вызываться при изменении позиции мыши. Здесь есть
const angle = getAngle(e.clientX, e.clientY);Разберём сразу функцию getAngle (e.clientX, e.clientY - позиция курсора вашей мыши).
getAngle - это функция, которая будет вычислять угол на который нужно повернуть объект, относительно горизонтальной оси координат, для наглядности приведу пример:
Горизонталь можете считать просто ось Ox, наш элемент стоит изначально горизонтально (помечен чёрным цветом), после поворота, он смотри на курсор и нам по сути нужно найти угол, на который был повёрнут
Зачем нам нужно поворачивать какой-то контейнер? Дело в том, что у каждой планеты есть свой контейнер:
Поворачивая этот контейнер, мы поворачиваем и планету, так как наша планета внутри (по DOM-дереву) этого контейнера, просто стоит в её левой части, вот как-то так:
В целом это всё что делает эта функция. Сам угол на который нужно повернуть находится через 2 катета, которые получаем через как вектора от центра объекта к курсору:
Ну и чтобы найти сам угол используем Math.atan2(y, x) - он вычисляет арктангенс (Тангенс это как раз deltaY/deltaX), арктангенс отдаёт значение в радианах, мы их переведём в градусы по формуле:
(Радианы*180)/PI = Градусы
На этом с функцией getAngle покончили.
Далее продолжим с handleMouseMove и инерцией, чтобы вычислить скорость броска, мы просто вычисляем разницу между курсором и предыдущим кадром и берём только 12%, чтобы планета не улетала слишком далеко.
Напомню, что само добавление градусов и постепенное уменьшение инерции у нас находится в функции animate.
Остальное вроде бы по коду понятно, если будут вопросы можете задавать на почту vaironfop@Mail.ru или в телеграмм-канале, так как на сайте на данный момент нет комментариев.
Теперь, чтобы всё завершить сделаем стили Planet.module.css:
.orbitInner {
position: absolute;
border-radius: 50%;
background-repeat: no-repeat;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
will-change: transform;
}
.picker {
position: absolute;
top: 50%;
left: 50%;
width: 50%;
height: 1px;
transform-origin: left center;
will-change: transform, filter;
pointer-events: none;
}
.dragging {
transition: none;
}
.innerOrbitCircle {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
border-radius: 50%;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
cursor: grab;
pointer-events: auto;
transition: transform 0.2s ease, filter 0.3s ease;
filter: brightness(1) drop-shadow(0 0 12px rgba(255, 255, 255, 0.25));
backface-visibility: hidden;
transform-style: preserve-3d;
animation: planetFloat 5s ease-in-out infinite;
}
.innerOrbitCircle:hover {
filter: brightness(1.1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.7));
}
.innerOrbitCircle:active {
cursor: grabbing;
}
@keyframes planetFloat {
0% {
translate: 0 0;
}
50% {
translate: 0 -4px;
}
100% {
translate: 0 0;
}
}Тут добавлена небольшая анимация, что планеты немного “плавают” и при наведении на планеты они светятся. CSS я сильно не разбираю, так как иначе эта была бы статья про позиционирование элементов, ну и особо интересного здесь ничего нет.
Финальная часть
Всё, осталось вывести планеты в App.tsx
import { PlanetSystem } from "./components/PlanetSystem/PlanetSystem"
function App() {
return (
<div className="intro">
<PlanetSystem/>
</div>
)
}
export default AppПроверяем и видим:
Поздравляю!
Спасибо большое за то, что посмотрели статью, надеюсь вы смогли вынести для себя что-то полезное. Этот код легко улучшить, добавить свои плюшки по типу перехода на разные орбиты, при клике на планету выводить что-то и тому подобное. Но главное что здесь было это работа с анимацией, нахождение угла, drag&drop и всякие интересные штуки по типу работы анимаций на разных устройствах.
Если вам понравилась такая статья, вы планируете улучшаться в веб-разработке и вам интересно, что происходит в ней, то можете подписаться на телеграм-канал WEBCOS
Успехов в дальнейших проектах! <3


