09고리를 두른 지구본
타원으로 눌러 그린 고리가 지구본 앞뒤로 갈라져 지나가며 입체감을 만듭니다.
쓰임프리미엄 서비스 소개, 브랜드 로고 애니메이션 배경
ellipse projectionocclusion splitperspectiveScale

<main class="stage">
<div class="rh" id="rh">
<p class="rh__hint">
<svg class="rh__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><ellipse cx="128" cy="128" rx="100" ry="36" fill="none" stroke="currentColor" stroke-width="10" opacity=".35"/><circle cx="128" cy="128" r="52"/></g></svg>
<span>고리의 절반은 구 뒤로, 절반은 구 앞으로 — 앞뒤를 갈라 그립니다</span>
</p>
<div class="rh__field" id="field" role="img" aria-label="토성 고리 지구본">
<canvas class="rh__cv" id="cv"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 토성 고리 지구본 — 캔버스 rAF(기울인 타원 고리를 z 부호로 앞/뒤 분리해 지구본과 교차 렌더).
// CSS 는 무대·판만. 파스텔 팔레트(BD26).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
.rh {
--rh-sphere: #2a2432; // 지구본 바닥 — 저채도 회보라
--rh-deep: #3e3648; // 편구 하이라이트
--rh-ring: #c8a8b8; // 고리 안쪽 — 더스티 로즈 파스텔
--rh-ring-hi: #ebe0d6; // 고리 중앙 밴드 — 크림 골드 파스텔
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rh__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.rh__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.rh__field {
position: relative; height: 210px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
}
.rh__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.rh__cv { display: block; width: 100%; height: 100%; }
@media (max-width: 340px) {
.rh { gap: $sp-1; }
.rh__field { height: 110px; }
.rh__hint { font-size: 10px; }
}
// reduce — 루프가 안 돈다. 고리와 구가 겹친 한 장만.
@media (prefers-reduced-motion: reduce) {
.rh__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rh {
--rh-sphere: #2a2432;
--rh-deep: #3e3648;
--rh-ring: #c8a8b8;
--rh-ring-hi: #ebe0d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rh__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.rh__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.rh__field {
position: relative;
height: 210px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
}
.rh__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.rh__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rh {
gap: 4px;
}
.rh__field {
height: 110px;
}
.rh__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.rh__field {
cursor: default !important;
}
}// 토성 고리 지구본 — 기울어진 타원 고리를 구 앞/뒤로 나눠 그린다(occlusion split).
// 고리 위 각 점을 지구본과 같은 Y축 자전 + X축 기울기로 회전시켜 z 부호로 앞/뒤를 가른다.
// 뒤(z<0)는 구보다 먼저, 앞(z>=0)은 구보다 나중에 그려 자연스러운 가림을 만든다(painter's algorithm 변형).
// 원근 스케일(perspective scale) — 카메라에서 먼 부분은 살짝 작게 그려 깊이를 강조한다.
var root = document.getElementById('rh');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var last = 0, raf = 0;
var rotY = 0;
var TILT = 0.62; // 고리 기울기(x축 회전) — 고정, 토성 특유의 각도
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 고리 위 점(반지름 rad, 각도 phi)을 기울이고 자전시켜 카메라 좌표로
function ringPoint(rad, phi) {
var x0 = Math.cos(phi) * rad, z0 = Math.sin(phi) * rad, y0 = 0;
// x축 기울기(TILT)
var y1 = y0 * Math.cos(TILT) - z0 * Math.sin(TILT);
var z1 = y0 * Math.sin(TILT) + z0 * Math.cos(TILT);
// y축 자전(rotY)
var x2 = x0 * Math.cos(rotY) + z1 * Math.sin(rotY);
var z2 = -x0 * Math.sin(rotY) + z1 * Math.cos(rotY);
return { x: x2, y: y1, z: z2 };
}
function drawRingHalf(ctx, cx, cy, R, colorInner, colorOuter, wantFront) {
var innerR = R * 1.35, outerR = R * 1.85;
var steps = 160;
ctx.lineWidth = (outerR - innerR) / R * R * 0.02;
for (var band = 0; band < 3; band++) {
var rad = innerR + (outerR - innerR) * (band / 2.4);
ctx.beginPath();
var pen = false;
for (var s = 0; s <= steps; s++) {
var phi = (s / steps) * TAU;
var q = ringPoint(rad, phi);
var isFront = q.z >= 0;
if (isFront === wantFront) {
var perspectiveScale = 1 + q.z * 0.06; // 원근 스케일 — z 에 따라 살짝 확대/축소
var px = cx + q.x * R * perspectiveScale;
var py = cy - q.y * R * perspectiveScale;
if (pen) ctx.lineTo(px, py); else { ctx.moveTo(px, py); pen = true; }
} else pen = false;
}
ctx.strokeStyle = band === 1 ? colorOuter : colorInner;
ctx.globalAlpha = wantFront ? 0.92 : 0.55;
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function draw() {
ctx.clearRect(0, 0, W, H);
var cx = W / 2, cy = H / 2;
var R = Math.min(W, H) * 0.24;
var cs = getComputedStyle(root);
var C_sphere = cs.getPropertyValue('--rh-sphere').trim();
var C_deep = cs.getPropertyValue('--rh-deep').trim();
var C_ring = cs.getPropertyValue('--rh-ring').trim();
var C_ringHi = cs.getPropertyValue('--rh-ring-hi').trim();
// 1) 고리 뒷부분(구 뒤로 지나가는 절반)
drawRingHalf(ctx, cx, cy, R, C_ring, C_ringHi, false);
// 2) 지구본 — 편구 셰이딩
var g = ctx.createRadialGradient(cx - R * 0.35, cy - R * 0.4, R * 0.1, cx, cy, R);
g.addColorStop(0, C_deep);
g.addColorStop(1, C_sphere);
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.fillStyle = g;
ctx.fill();
// 3) 고리 앞부분(구 앞을 지나가는 절반) — 지구본을 덮어 가림을 완성
drawRingHalf(ctx, cx, cy, R, C_ring, C_ringHi, true);
}
function frame(now) {
var dt = last ? Math.min((now - last) / 1000, 0.05) : 0.016;
last = now;
rotY += dt * 0.26;
draw();
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); draw(); });
fit();
if (reduce) {
draw();
} else {
raf = requestAnimationFrame(frame);
}// GlobeSaturnRingHalo — vanilla 판(run/304/demo/09-globe-saturn-ring-halo/index.html) 의 React 포트.
// 기울어진 타원 고리를 구 앞/뒤로 나눠 그린다(occlusion split). 고리 위 각 점을 지구본과 같은
// Y축 자전 + X축 기울기로 회전시켜 z 부호로 앞/뒤를 가른다(painter's algorithm 변형).
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 미리보기가 없다 — props 는 CSS 변수로 무대 격식에만 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GlobeSaturnRingHalo.module.scss";
export type GlobeSaturnRingHaloProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const TAU = Math.PI * 2;
const FIELD_LABEL = "토성 고리 지구본";
const TILT = 0.62; // 고리 기울기(x축 회전) — 고정, 토성 특유의 각도
export default function GlobeSaturnRingHalo({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ff9ebb",
}: GlobeSaturnRingHaloProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rotYRef = useRef(0) as Box<number>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 고리 위 점(반지름 rad, 각도 phi)을 기울이고 자전시켜 카메라 좌표로
function ringPoint(rad: number, phi: number): { x: number; y: number; z: number } {
const x0 = Math.cos(phi) * rad, z0 = Math.sin(phi) * rad, y0 = 0;
// x축 기울기(TILT)
const y1 = y0 * Math.cos(TILT) - z0 * Math.sin(TILT);
const z1 = y0 * Math.sin(TILT) + z0 * Math.cos(TILT);
// y축 자전(rotY)
const x2 = x0 * Math.cos(rotYRef.current) + z1 * Math.sin(rotYRef.current);
const z2 = -x0 * Math.sin(rotYRef.current) + z1 * Math.cos(rotYRef.current);
return { x: x2, y: y1, z: z2 };
}
function drawRingHalf(ctx: CanvasRenderingContext2D, cx: number, cy: number, R: number, colorInner: string, colorOuter: string, wantFront: boolean): void {
const innerR = R * 1.35, outerR = R * 1.85;
const steps = 160;
for (let band = 0; band < 3; band++) {
const rad = innerR + (outerR - innerR) * (band / 2.4);
ctx.beginPath();
let pen = false;
for (let s = 0; s <= steps; s++) {
const phi = (s / steps) * TAU;
const q = ringPoint(rad, phi);
const isFront = q.z >= 0;
if (isFront === wantFront) {
// 원근 스케일 — z 에 따라 살짝 확대/축소(카메라에 가까울수록 크게 투영되는 효과를 반경에 반영)
const persp = 1 + q.z * 0.06;
const px = cx + q.x * R * persp;
const py = cy - q.y * R * persp;
if (pen) ctx.lineTo(px, py); else { ctx.moveTo(px, py); pen = true; }
} else pen = false;
}
ctx.strokeStyle = band === 1 ? colorOuter : colorInner;
ctx.globalAlpha = wantFront ? 0.92 : 0.55;
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function draw(): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
ctx.clearRect(0, 0, W, H);
const cx = W / 2, cy = H / 2;
const R = Math.min(W, H) * 0.24;
const cs = getComputedStyle(root);
const C_sphere = cs.getPropertyValue("--rh-sphere").trim();
const C_deep = cs.getPropertyValue("--rh-deep").trim();
const C_ring = cs.getPropertyValue("--rh-ring").trim();
const C_ringHi = cs.getPropertyValue("--rh-ring-hi").trim();
// 1) 고리 뒷부분(구 뒤로 지나가는 절반)
drawRingHalf(ctx, cx, cy, R, C_ring, C_ringHi, false);
// 2) 지구본 — 편구 셰이딩
const g = ctx.createRadialGradient(cx - R * 0.35, cy - R * 0.4, R * 0.1, cx, cy, R);
g.addColorStop(0, C_deep);
g.addColorStop(1, C_sphere);
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.fillStyle = g;
ctx.fill();
// 3) 고리 앞부분(구 앞을 지나가는 절반) — 지구본을 덮어 가림을 완성
drawRingHalf(ctx, cx, cy, R, C_ring, C_ringHi, true);
}
function frame(now: number): void {
const dt = lastRef.current ? Math.min((now - lastRef.current) / 1000, 0.05) : 0.016;
lastRef.current = now;
rotYRef.current += dt * 0.26;
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
const onResize = (): void => { fit(); draw(); };
window.addEventListener("resize", onResize);
if (reduced()) {
draw(); // reduce — 루프 없이 고리와 구가 겹친 한 장만
} else {
lastRef.current = 0;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><ellipse cx="128" cy="128" rx="100" ry="36" fill="none" stroke="currentColor" stroke-width="10" opacity=".35"/><circle cx="128" cy="128" r="52"/></g></svg>
<span>고리의 절반은 구 뒤로, 절반은 구 앞으로 — 앞뒤를 갈라 그립니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="img"
aria-label={FIELD_LABEL}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
</div>
</main>
);
}09 고리를 두른 지구본 — 측정 누적면적 19.972% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 3D 지구본 캔버스 장면 9종 — Three.js 없이
이 데모가 속한 글
3D 지구본 캔버스 장면 9종 — Three.js 없이3D 지구본은 웹사이트에 우주적인 규모감을 주는 장면입니다. 위성 궤도와 항로 곡선, 낮밤 경계까지 아홉 종을 Three.js 나 WebGL 없이 캔버스 2D 계산만으로 만들었고, 실측으로 가장 넓게 움직인 08 입 이 글의 데모 전부 보기


