Motiva
Мои коллекции
← каталог

Globe

Глобус из точек: сфера вращается сама и раскручивается рукой, между узлами тянутся дуги больших кругов. Задняя полусфера отсекается по знаку z — глубина получается сама, без сортировки.

cssautodragspringadvanced0reduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
backdrop?string | falseBACKDROPПодложка: CSS-значение `background` или `false`, когда фон задаёт секция.
dots?number900Сколько точек рассыпать по сфере.
rpm?number2.4Оборотов в минуту при автовращении.
tilt?number20Наклон оси к зрителю, градусы.
color?stringЦвет точек и дуг (любой CSS-цвет). По умолчанию берётся акцент дизайн-системы (`--ds-accent`), поэтому глобус перекрашивается вместе со страницей.
markers?GlobeMarker[]DEFAULT_MARKERSУзлы, между которыми тянутся дуги (по кругу: 0→1→2→…→0).
children?ReactNodeСодержимое поверх глобуса.
GlobeMarker — что внутри
  • lat: number— Широта в градусах: +север, −юг.
  • lon: number— Долгота в градусах: +восток, −запад.
  • label?: string— Подпись — только для читаемости кода, на холст не выводится.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/globe.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import type { ReactNode } from "react";

/** Подложка по умолчанию — как в витрине. */
const BACKDROP = "oklch(0.14 0.015 270)";

export interface GlobeMarker {
  /** Широта в градусах: +север, −юг. */
  lat: number;
  /** Долгота в градусах: +восток, −запад. */
  lon: number;
  /** Подпись — только для читаемости кода, на холст не выводится. */
  label?: string;
}

export interface GlobeProps {
  /** Подложка: CSS-значение `background` или `false`, когда фон задаёт секция. */
  backdrop?: string | false;
  /** Сколько точек рассыпать по сфере. */
  dots?: number;
  /** Оборотов в минуту при автовращении. */
  rpm?: number;
  /** Наклон оси к зрителю, градусы. */
  tilt?: number;
  /**
   * Цвет точек и дуг (любой CSS-цвет). По умолчанию берётся акцент дизайн-системы
   * (`--ds-accent`), поэтому глобус перекрашивается вместе со страницей.
   */
  color?: string;
  /** Узлы, между которыми тянутся дуги (по кругу: 0→1→2→…→0). */
  markers?: GlobeMarker[];
  /** Содержимое поверх глобуса. */
  children?: ReactNode;
}

const PHI = (1 + Math.sqrt(5)) / 2;
const DEG = Math.PI / 180;
/** Сегментов в дуге: меньше — виден многоугольник, больше — лишние проекции. */
const ARC_STEPS = 40;
/** Сколько радиан поворота даёт пиксель протяжки. */
const DRAG_RAD_PER_PX = 0.006;
/** Потолок брошенной скорости, рад/с: выше глобус превращается в мельтешение. */
const MAX_FLING = 6;

const DEFAULT_MARKERS: GlobeMarker[] = [
  { lat: 55.75, lon: 37.62, label: "Москва" },
  { lat: 40.71, lon: -74.01, label: "Нью-Йорк" },
  { lat: 35.68, lon: 139.69, label: "Токио" },
  { lat: -23.55, lon: -46.63, label: "Сан-Паулу" },
  { lat: 1.35, lon: 103.82, label: "Сингапур" },
];

type Vec = { x: number; y: number; z: number };

const fromLatLon = (lat: number, lon: number): Vec => {
  const a = lat * DEG;
  const b = lon * DEG;
  return { x: Math.cos(a) * Math.sin(b), y: Math.sin(a), z: Math.cos(a) * Math.cos(b) };
};

/**
 * Глобус из точек: сфера вращается сама, её можно раскрутить рукой, между узлами
 * тянутся дуги больших кругов.
 *
 * Класс приёма для каталога новый: состояние записи — не DOM и не текстура, а СПИСОК
 * ТОЧЕК НА СФЕРЕ. Каждый кадр их поворачивает одна матрица, дальше ортографическая
 * проекция, и всё, что попало на заднюю полусферу, отбрасывается по знаку z — глубина
 * получается сама, без сортировки и без буфера.
 *
 * Три вещи, ради которых запись и написана:
 * 1. Россыпь точек берётся решёткой Фибоначчи, а не двойным циклом по широте и долготе:
 *    у сетки «меридиан × параллель» точки скучиваются к полюсам, и шар выходит комковатым.
 * 2. Дуга между узлами — не отрезок и не безье в экранных координатах, а SLERP по самой
 *    сфере: тогда она лежит на большом круге и остаётся дугой при любом повороте.
 *    Приподнята над поверхностью синусом, поэтому читается «полёт», а не царапина.
 * 3. Рука не двигает глобус, а ЗАДАЁТ ЕМУ УГЛОВУЮ СКОРОСТЬ. Поэтому отпущенный глобус
 *    доворачивается по инерции и плавно возвращается к своей базовой скорости — без
 *    рывка на границе жеста, который даёт прямое присваивание угла.
 *
 * Цикл спит вне кадра и в фоновой вкладке. Reduced-motion / data-shot — один статичный
 * кадр, слушателей жеста нет вовсе.
 *
 * @example
 * <Globe dots={900} rpm={2.4} markers={[{ lat: 55.75, lon: 37.62 }]} />
 */
export function Globe({
  backdrop = BACKDROP,
  dots = 900,
  rpm = 2.4,
  tilt = 20,
  color,
  markers = DEFAULT_MARKERS,
  children,
}: GlobeProps = {}) {
  const hostRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    if (!host || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // Цвет берётся ВЫЧИСЛЕННЫМ: в canvas нельзя написать var(--ds-accent) — невалидная
    // строка молча игнорируется, и холст остаётся чёрным. Токен читается со стиля хоста,
    // поэтому глобус следует акценту страницы, а не спорит с ним зашитым фиолетовым.
    // Перечитывается при смене темы: у светлой и тёмной акцент разной светлоты.
    const pickPaint = () =>
      color || getComputedStyle(host).getPropertyValue("--ds-accent").trim() || "#a78bfa";
    let paint = pickPaint();

    // решётка Фибоначчи: равномерная россыпь без сгущения у полюсов
    const cloud: Vec[] = Array.from({ length: dots }, (_, i) => {
      const y = 1 - (2 * (i + 0.5)) / dots;
      const r = Math.sqrt(Math.max(0, 1 - y * y));
      const th = (2 * Math.PI * i) / PHI;
      return { x: r * Math.cos(th), y, z: r * Math.sin(th) };
    });
    const nodes = markers.map((m) => fromLatLon(m.lat, m.lon));

    const base = (rpm * 2 * Math.PI) / 60; // рад/с автовращения
    const tiltRad = tilt * DEG;
    let spin = 0; // угол вокруг оси
    let extra = 0; // угловая скорость от руки, гаснет экспонентой
    let raf = 0;
    let last = 0;
    let t0 = 0;
    let inView = true;
    let pageVisible = !document.hidden;
    let cx = 0;
    let cy = 0;
    let R = 0;

    // поворот вокруг оси Y, затем наклон вокруг X: одна матрица на все точки кадра
    const project = (p: Vec, sin: number, cos: number, sinT: number, cosT: number) => {
      const x = p.x * cos + p.z * sin;
      const z1 = -p.x * sin + p.z * cos;
      const y = p.y * cosT - z1 * sinT;
      const z = p.y * sinT + z1 * cosT;
      return { sx: cx + x * R, sy: cy - y * R, z };
    };

    const slerp = (a: Vec, b: Vec, t: number): Vec => {
      const dot = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z));
      const om = Math.acos(dot);
      if (om < 1e-4) return a;
      const s = Math.sin(om);
      const k1 = Math.sin((1 - t) * om) / s;
      const k2 = Math.sin(t * om) / s;
      return { x: a.x * k1 + b.x * k2, y: a.y * k1 + b.y * k2, z: a.z * k1 + b.z * k2 };
    };

    const draw = (time: number) => {
      const sin = Math.sin(spin);
      const cos = Math.cos(spin);
      const sinT = Math.sin(tiltRad);
      const cosT = Math.cos(tiltRad);

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // лимб: тонкая кромка шара, чтобы точки читались как поверхность, а не как облако
      ctx.beginPath();
      ctx.arc(cx, cy, R, 0, Math.PI * 2);
      ctx.strokeStyle = paint;
      ctx.globalAlpha = 0.16;
      ctx.lineWidth = 1;
      ctx.stroke();

      ctx.fillStyle = paint;
      for (const p of cloud) {
        const q = project(p, sin, cos, sinT, cosT);
        if (q.z <= 0) continue; // задняя полусфера не рисуется вовсе
        // ближе к зрителю — ярче и крупнее: глубина берётся из той же z, без освещения
        ctx.globalAlpha = 0.14 + 0.5 * q.z;
        const r = 0.7 + 0.9 * q.z;
        ctx.beginPath();
        ctx.arc(q.sx, q.sy, r, 0, Math.PI * 2);
        ctx.fill();
      }

      // дуги больших кругов между узлами: SLERP по сфере, приподнят синусом
      ctx.strokeStyle = paint;
      ctx.lineWidth = 1.4;
      for (let i = 0; i < nodes.length; i++) {
        const a = nodes[i];
        const b = nodes[(i + 1) % nodes.length];
        if (!a || !b || nodes.length < 2) break;
        let prev: { sx: number; sy: number; z: number } | null = null;
        for (let s = 0; s <= ARC_STEPS; s++) {
          const t = s / ARC_STEPS;
          const m = slerp(a, b, t);
          const lift = 1 + 0.12 * Math.sin(Math.PI * t);
          const q = project({ x: m.x * lift, y: m.y * lift, z: m.z * lift }, sin, cos, sinT, cosT);
          if (prev && prev.z > 0 && q.z > 0) {
            ctx.globalAlpha = 0.1 + 0.45 * Math.min(prev.z, q.z);
            ctx.beginPath();
            ctx.moveTo(prev.sx, prev.sy);
            ctx.lineTo(q.sx, q.sy);
            ctx.stroke();
          }
          prev = q;
        }
      }

      // узлы: точка плюс расходящееся кольцо, фаза у каждого своя
      const beat = (time - t0) / 1000;
      nodes.forEach((n, i) => {
        const q = project(n, sin, cos, sinT, cosT);
        if (q.z <= 0) return;
        ctx.globalAlpha = 0.35 + 0.65 * q.z;
        ctx.beginPath();
        ctx.arc(q.sx, q.sy, 2.6, 0, Math.PI * 2);
        ctx.fill();
        if (frozen) return;
        const phase = (beat * 0.55 + i / nodes.length) % 1;
        ctx.globalAlpha = (1 - phase) * 0.5 * q.z;
        ctx.beginPath();
        ctx.arc(q.sx, q.sy, 3 + phase * 16, 0, Math.PI * 2);
        ctx.lineWidth = 1;
        ctx.stroke();
      });

      ctx.globalAlpha = 1;
    };

    const frame = (t: number) => {
      raf = 0;
      const dt = Math.min(0.1, (t - last) / 1000) || 0.016;
      last = t;
      // рука задаёт СКОРОСТЬ, а не угол: поэтому отпущенный глобус доворачивается
      extra *= Math.exp(-2.6 * dt);
      spin += (base + extra) * dt;
      draw(t);
      schedule();
    };

    const schedule = () => {
      if (!raf && inView && pageVisible && !frozen) raf = requestAnimationFrame(frame);
    };
    const stop = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const resize = () => {
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      const w = host.clientWidth;
      const h = host.clientHeight;
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      // после смены width контекст сброшен: трансформ ставим заново
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      cx = w / 2;
      cy = h / 2;
      R = Math.min(w, h) * 0.4;
      draw(performance.now());
    };

    const ro = new ResizeObserver(resize);
    ro.observe(host);
    // тему переключают классом на <html> — это единственный сигнал, что токен уехал
    const themeWatch = new MutationObserver(() => {
      paint = pickPaint();
      draw(performance.now());
    });
    themeWatch.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    const io = new IntersectionObserver(([e]) => {
      inView = !!e?.isIntersecting;
      if (inView) {
        last = performance.now();
        schedule();
      } else {
        stop();
      }
    });
    io.observe(host);
    const onVis = () => {
      pageVisible = !document.hidden;
      if (pageVisible) {
        last = performance.now();
        schedule();
      } else {
        stop();
      }
    };
    document.addEventListener("visibilitychange", onVis);

    // жест: один слушатель на холст, скорость считается из смещения ЗА ВРЕМЯ
    let dragging = false;
    let lastX = 0;
    let lastT = 0;
    const onDown = (e: PointerEvent) => {
      dragging = true;
      lastX = e.clientX;
      lastT = e.timeStamp;
      canvas.setPointerCapture(e.pointerId);
    };
    const onMove = (e: PointerEvent) => {
      if (!dragging) return;
      const dt = Math.max(8, e.timeStamp - lastT) / 1000;
      const dx = e.clientX - lastX;
      lastX = e.clientX;
      lastT = e.timeStamp;
      const turn = dx * DRAG_RAD_PER_PX;
      spin += turn;
      // скорость руки берётся ОТ ВРЕМЕНИ, а не от одного смещения: без деления на dt
      // резкий рывок мышью отправлял глобус почти на оборот за кадр. Сглаживание
      // гасит дрожь последнего события, потолок оставляет вращение читаемым.
      const w = turn / dt;
      extra = Math.max(-MAX_FLING, Math.min(MAX_FLING, extra * 0.4 + w * 0.6));
    };
    const onUp = (e: PointerEvent) => {
      if (!dragging) return;
      dragging = false;
      if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
    };
    if (!frozen) {
      canvas.addEventListener("pointerdown", onDown);
      canvas.addEventListener("pointermove", onMove);
      canvas.addEventListener("pointerup", onUp);
      canvas.addEventListener("pointercancel", onUp);
    }

    t0 = performance.now();
    resize();
    schedule();

    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      themeWatch.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      canvas.removeEventListener("pointerdown", onDown);
      canvas.removeEventListener("pointermove", onMove);
      canvas.removeEventListener("pointerup", onUp);
      canvas.removeEventListener("pointercancel", onUp);
    };
  }, [dots, rpm, tilt, color, markers]);

  return (
    <div
      ref={hostRef}
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    >
      <canvas
        ref={canvasRef}
        style={{
          position: "absolute",
          inset: 0,
          width: "100%",
          height: "100%",
          cursor: "grab",
          touchAction: "pan-y",
        }}
      />
      {children && (
        <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none" }}>
          {children}
        </div>
      )}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Globe (MIT), реализация своя: без cobe и без WebGLv1.0.0