ЛоготипЛоготип
Главная

Как сделать вращающиеся планеты вокруг своей орбиты?

Как сделать вращающиеся планеты вокруг своей орбиты?

Как сделать вращающиеся планеты вокруг своей орбиты?

В процессе разработки 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;

  • инерцию;

Код может показаться большим, но на самом деле его можно разделить на три независимые части:

  1. анимация вращения;

  2. обработка перетаскивания;

  3. визуальные эффекты.

Я сразу выведу код, с комментариями, сейчас будем разбираться по блокам

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

 

Рекомендуем