03지구본을 도는 위성 궤도
작은 점 여러 개가 타원 궤도를 따라 돌며, 지구본 뒤로 지날 때는 가려집니다.
쓰임우주·통신 서비스 소개, 실시간 트래픽 시각화
elliptical orbitpainter's algorithmz-sort

<main class="stage">
<div class="gs" id="gs">
<p class="gs__hint">
<svg class="gs__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M144 128a16 16 0 1 1-16-16a16 16 0 0 1 16 16" opacity=".2"/><path d="M232 112a24 24 0 0 0-22.84-23.9A64 64 0 0 0 84 76.53A40 40 0 0 0 72 152h8.91a64 64 0 0 0 106 30.16l-9.46-13.27A48 48 0 0 1 93.54 152H192a24 24 0 0 0 40-40m-56-24a48 48 0 0 1 46.31 35.44A8 8 0 0 1 214.58 132H96a48 48 0 0 1 48-44h16Zm36 32h-1.28l1.1 3.53c.05-.5.18-1 .18-1.53Z" fill-rule="evenodd"/></g></svg>
<span>위성 여러 개가 저마다 다른 타원 궤도를 돕니다</span>
</p>
<div class="gs__field" id="field" role="application" aria-label="위성 궤도 실험판" tabindex="0" data-sort="z-sort">
<canvas class="gs__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-sky;
@include stage($stage-ink);
.gs {
--gs-sphere: #262230; // 지구본 바닥 — 저채도 회보라
--gs-deep: #3a3450; // 편구 하이라이트 — 파스텔 라일락 톤 낮게
--gs-orbit: #474152; // 느린 궤도선 — 어두운 회
--gs-orbit-hi: #6a6478; // 빠른 궤도선 — 한 단계 밝은 회
--gs-sat: #b8c4d4; // 위성 — 회청 파스텔
--gs-sat-hi: #ebe6d6; // 빠른 위성 — 크림 골드 파스텔
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gs__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;
}
.gs__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.gs__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);
}
.gs__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.gs__cv { display: block; width: 100%; height: 100%; }
@media (max-width: 340px) {
.gs { gap: $sp-1; }
.gs__field { height: 110px; }
.gs__hint { font-size: 10px; }
}
// reduce — 루프가 안 돈다. 멈춘 한 장에 궤도와 위성이 다 보인다.
@media (prefers-reduced-motion: reduce) {
.gs__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);
}
.gs {
--gs-sphere: #262230;
--gs-deep: #3a3450;
--gs-orbit: #474152;
--gs-orbit-hi: #6a6478;
--gs-sat: #b8c4d4;
--gs-sat-hi: #ebe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gs__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;
}
.gs__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.gs__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);
}
.gs__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.gs__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.gs {
gap: 4px;
}
.gs__field {
height: 110px;
}
.gs__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gs__field {
cursor: default !important;
}
}// 위성 궤도 지구본 — 각 위성은 3D 타원 궤도 위를 등각 속도로 돈다.
// 화가 알고리즘(painter's algorithm): 지구본(원)과 위성을 z 깊이(z-sort)로 한 번에 정렬해 뒤→앞 순서로 그린다.
// 지구본 뒤(z<0)의 위성은 지구본 원이 나중에 덮으니 자연히 가려진다.
var root = document.getElementById('gs');
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;
// 위성 궤도 — a(장반경 배율) b(단반경 배율) inc(궤도 기울기 rad) phase(시작 위상) speed(각속도)
var SATS = [
{ a: 1.62, b: 1.62, inc: 0.00, phase: 0.0, speed: 0.55 },
{ a: 1.95, b: 1.38, inc: 0.55, phase: 2.1, speed: 0.42 },
{ a: 2.30, b: 1.72, inc: 1.05, phase: 4.0, speed: 0.34 },
{ a: 1.80, b: 1.24, inc: 1.62, phase: 1.2, speed: 0.48 },
{ a: 2.55, b: 2.05, inc: 0.82, phase: 5.2, speed: 0.28 },
{ a: 1.44, b: 1.44, inc: 2.20, phase: 3.3, speed: 0.62 }
];
var rotY = 0; // 지구본 자전
var last = 0, raf = 0;
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);
}
// 궤도 평면계 좌표(ex, ey)를 기울기 inc 만큼 돌려 3D 로, 다시 지구본 자전(rotY)을 곱해 카메라계로
function satPos(s, t) {
var th = s.phase + t * s.speed;
var ex = Math.cos(th) * s.a;
var ey = Math.sin(th) * s.b;
var y = ey * Math.cos(s.inc);
var zi = ey * Math.sin(s.inc);
var cx = Math.cos(rotY), sx = Math.sin(rotY);
return {
x: ex * cx + zi * sx,
y: y,
z: -ex * sx + zi * cx
};
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
var cx = W / 2, cy = H / 2;
var R = Math.min(W, H) * 0.26; // 지구본 반지름 — 궤도가 화면 안에 다 들어오게
var cs = getComputedStyle(root);
var C_sphere = cs.getPropertyValue('--gs-sphere').trim();
var C_deep = cs.getPropertyValue('--gs-deep').trim();
var C_orbit = cs.getPropertyValue('--gs-orbit').trim();
var C_orbitHi = cs.getPropertyValue('--gs-orbit-hi').trim();
var C_sat = cs.getPropertyValue('--gs-sat').trim();
var C_satHi = cs.getPropertyValue('--gs-sat-hi').trim();
// 위성 위치 전부 계산
var sats = [];
for (var i = 0; i < SATS.length; i++) {
var p = satPos(SATS[i], t);
sats.push({ def: SATS[i], p: p });
}
// ── 1단계: 궤도선 (언제나 지구본보다 아래 층)
for (var k = 0; k < SATS.length; k++) {
var s = SATS[k];
ctx.beginPath();
var started = false;
for (var seg = 0; seg <= 128; seg++) {
var th = (seg / 128) * TAU;
var ex = Math.cos(th) * s.a;
var ey = Math.sin(th) * s.b;
var yy = ey * Math.cos(s.inc);
var zi = ey * Math.sin(s.inc);
var ccx = Math.cos(rotY), ssx = Math.sin(rotY);
var x = ex * ccx + zi * ssx;
var z = -ex * ssx + zi * ccx;
// 정사도 — z 는 깊이로만 쓴다
var px = cx + x * R, py = cy - yy * R;
if (!started) { ctx.moveTo(px, py); started = true; }
else ctx.lineTo(px, py);
}
ctx.closePath();
// 뒤쪽 절반은 흐리게 — z 부호로 알파를 갈라 입체를 읽게 한다
ctx.strokeStyle = s.speed > 0.45 ? C_orbitHi : C_orbit;
ctx.lineWidth = 1;
ctx.stroke();
}
// ── 2단계: 지구본 + 위성을 z 로 한번에 정렬(화가 알고리즘)
var items = [{ kind: 'globe', z: 0 }];
for (var m = 0; m < sats.length; m++) {
items.push({ kind: 'sat', z: sats[m].p.z, ref: sats[m] });
}
items.sort(function (a, b) { return a.z - b.z; }); // 뒤(z 작음)부터 — z-sort 키로 오름차순
for (var q = 0; q < items.length; q++) {
var it = items[q];
if (it.kind === 'globe') {
// 지구본 — 위성 궤도가 살아 보이게 위성보다 반지름을 작게 둔다
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.fillStyle = C_sphere;
ctx.fill();
// 은은한 편구 셰이딩 — 왼쪽 위가 밝다
var g = ctx.createRadialGradient(cx - R * 0.35, cy - R * 0.35, R * 0.1, cx, cy, R);
g.addColorStop(0, C_deep);
g.addColorStop(1, C_sphere);
ctx.fillStyle = g;
ctx.fill();
} else {
var sp = it.ref;
var depth = (sp.p.z + 2.6) / 5.2; // 0..1
var px2 = cx + sp.p.x * R, py2 = cy - sp.p.y * R;
ctx.globalAlpha = 0.35 + depth * 0.65;
// 위성 — 크기도 깊이로
var r2 = 2.2 + depth * 2.6;
ctx.fillStyle = sp.def.speed > 0.45 ? C_satHi : C_sat;
ctx.beginPath();
ctx.arc(px2, py2, r2, 0, TAU);
ctx.fill();
// 앞쪽 위성에만 발광 헤일로 — 뒤는 지구본에 가려졌음이 또렷해진다
if (depth > 0.5) {
ctx.globalAlpha = 0.28 * depth;
ctx.beginPath();
ctx.arc(px2, py2, r2 * 2.4, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
}
}
}
function frame(now) {
var dt = last ? Math.min((now - last) / 1000, 0.05) : 0.016;
last = now;
rotY += dt * 0.18;
draw(now * 0.001);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); draw(0); });
fit();
if (reduce) {
draw(0.4);
} else {
raf = requestAnimationFrame(frame);
}// GlobeSatelliteOrbit — vanilla 판(run/304/demo/03-globe-satellite-orbit/index.html) 의 React 포트.
// 각 위성은 3D 타원 궤도 위를 돈다. 화가 알고리즘(painter's algorithm)으로 지구본과 위성을 z 깊이로 정렬해 뒤→앞 그린다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 미리보기가 없다 — props 는 CSS 변수로 무대 격식에만 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GlobeSatelliteOrbit.module.scss";
export type GlobeSatelliteOrbitProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type SatDef = { a: number; b: number; inc: number; phase: number; speed: number };
type Vec3 = { x: number; y: number; z: number };
const TAU = Math.PI * 2;
const FIELD_LABEL = "위성 궤도 실험판";
// 위성 궤도 — a(장반경 배율) b(단반경 배율) inc(궤도 기울기 rad) phase(시작 위상) speed(각속도)
const SATS: SatDef[] = [
{ a: 1.62, b: 1.62, inc: 0.00, phase: 0.0, speed: 0.55 },
{ a: 1.95, b: 1.38, inc: 0.55, phase: 2.1, speed: 0.42 },
{ a: 2.30, b: 1.72, inc: 1.05, phase: 4.0, speed: 0.34 },
{ a: 1.80, b: 1.24, inc: 1.62, phase: 1.2, speed: 0.48 },
{ a: 2.55, b: 2.05, inc: 0.82, phase: 5.2, speed: 0.28 },
{ a: 1.44, b: 1.44, inc: 2.20, phase: 3.3, speed: 0.62 }
];
export default function GlobeSatelliteOrbit({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
}: GlobeSatelliteOrbitProps) {
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);
}
// 궤도 평면계 좌표를 기울기 inc 만큼 돌려 3D 로, 다시 지구본 자전(rotY)을 곱해 카메라계로
function satPos(s: SatDef, t: number): Vec3 {
const th = s.phase + t * s.speed;
const ex = Math.cos(th) * s.a;
const ey = Math.sin(th) * s.b;
const y = ey * Math.cos(s.inc);
const zi = ey * Math.sin(s.inc);
const cx = Math.cos(rotYRef.current), sx = Math.sin(rotYRef.current);
return {
x: ex * cx + zi * sx,
y: y,
z: -ex * sx + zi * cx
};
}
function draw(t: number): 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.26; // 지구본 반지름 — 궤도가 화면 안에 다 들어오게
const cs = getComputedStyle(root);
const C_sphere = cs.getPropertyValue("--gs-sphere").trim();
const C_deep = cs.getPropertyValue("--gs-deep").trim();
const C_orbit = cs.getPropertyValue("--gs-orbit").trim();
const C_orbitHi = cs.getPropertyValue("--gs-orbit-hi").trim();
const C_sat = cs.getPropertyValue("--gs-sat").trim();
const C_satHi = cs.getPropertyValue("--gs-sat-hi").trim();
const sats: { def: SatDef; p: Vec3 }[] = [];
for (let i = 0; i < SATS.length; i++) {
sats.push({ def: SATS[i], p: satPos(SATS[i], t) });
}
// 1단계: 궤도선 (언제나 지구본보다 아래 층)
const rotY = rotYRef.current;
for (let k = 0; k < SATS.length; k++) {
const s = SATS[k];
ctx.beginPath();
let started = false;
for (let seg = 0; seg <= 128; seg++) {
const th = (seg / 128) * TAU;
const ex = Math.cos(th) * s.a;
const ey = Math.sin(th) * s.b;
const yy = ey * Math.cos(s.inc);
const zi = ey * Math.sin(s.inc);
const ccx = Math.cos(rotY), ssx = Math.sin(rotY);
const x = ex * ccx + zi * ssx;
const z = -ex * ssx + zi * ccx;
const px = cx + x * R, py = cy - yy * R;
if (!started) { ctx.moveTo(px, py); started = true; }
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.strokeStyle = s.speed > 0.45 ? C_orbitHi : C_orbit;
ctx.lineWidth = 1;
ctx.stroke();
}
// 2단계: 지구본 + 위성을 z 로 한번에 정렬(화가 알고리즘)
const items: { kind: string; z: number; ref?: { def: SatDef; p: Vec3 } }[] = [{ kind: "globe", z: 0 }];
for (let m = 0; m < sats.length; m++) {
items.push({ kind: "sat", z: sats[m].p.z, ref: sats[m] });
}
items.sort((a, b) => a.z - b.z); // 뒤(z 작음)부터
for (let q = 0; q < items.length; q++) {
const it = items[q];
if (it.kind === "globe") {
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.fillStyle = C_sphere;
ctx.fill();
// 은은한 편구 셰이딩 — 왼쪽 위가 밝다
const g = ctx.createRadialGradient(cx - R * 0.35, cy - R * 0.35, R * 0.1, cx, cy, R);
g.addColorStop(0, C_deep);
g.addColorStop(1, C_sphere);
ctx.fillStyle = g;
ctx.fill();
} else if (it.ref) {
const sp = it.ref;
const depth = (sp.p.z + 2.6) / 5.2; // 0..1
const px2 = cx + sp.p.x * R, py2 = cy - sp.p.y * R;
ctx.globalAlpha = 0.35 + depth * 0.65;
const r2 = 2.2 + depth * 2.6;
ctx.fillStyle = sp.def.speed > 0.45 ? C_satHi : C_sat;
ctx.beginPath();
ctx.arc(px2, py2, r2, 0, TAU);
ctx.fill();
if (depth > 0.5) {
ctx.globalAlpha = 0.28 * depth;
ctx.beginPath();
ctx.arc(px2, py2, r2 * 2.4, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
}
}
}
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.18;
draw(now * 0.001);
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(0); };
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.4); // 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"><path d="M144 128a16 16 0 1 1-16-16a16 16 0 0 1 16 16" opacity=".2"/><path d="M232 112a24 24 0 0 0-22.84-23.9A64 64 0 0 0 84 76.53A40 40 0 0 0 72 152h8.91a64 64 0 0 0 106 30.16l-9.46-13.27A48 48 0 0 1 93.54 152H192a24 24 0 0 0 40-40m-56-24a48 48 0 0 1 46.31 35.44A8 8 0 0 1 214.58 132H96a48 48 0 0 1 48-44h16Zm36 32h-1.28l1.1 3.53c.05-.5.18-1 .18-1.53Z" fillRule="evenodd"/></g></svg>
<span>위성 여러 개가 저마다 다른 타원 궤도를 돕니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
</div>
</main>
);
}03 지구본을 도는 위성 궤도 — 측정 누적면적 14.463% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 3D 지구본 캔버스 장면 9종 — Three.js 없이
이 데모가 속한 글
3D 지구본 캔버스 장면 9종 — Three.js 없이3D 지구본은 웹사이트에 우주적인 규모감을 주는 장면입니다. 위성 궤도와 항로 곡선, 낮밤 경계까지 아홉 종을 Three.js 나 WebGL 없이 캔버스 2D 계산만으로 만들었고, 실측으로 가장 넓게 움직인 08 입 이 글의 데모 전부 보기


