← каталог
Globe
Глобус из точек: сфера вращается сама и раскручивается рукой, между узлами тянутся дуги больших кругов. Задняя полусфера отсекается по знаку z — глубина получается сама, без сортировки.
cssautodragspringadvanced0reduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| backdrop? | string | false | BACKDROP | Подложка: CSS-значение `background` или `false`, когда фон задаёт секция. |
| dots? | number | 900 | Сколько точек рассыпать по сфере. |
| rpm? | number | 2.4 | Оборотов в минуту при автовращении. |
| tilt? | number | 20 | Наклон оси к зрителю, градусы. |
| 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Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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