07複数の惑星が回る太陽系モデル
惑星ごとに違う公転周期で軌道を回り、ケプラーの式で軌道位置ごとに速度が変わります。
使いどころ教育コンテンツページ、科学館・展示紹介サイト
Kepler's second laworbital periodellipse parametric

<main class="stage">
<div class="or" id="or">
<p class="or__hint">
<svg class="or__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24" opacity=".2"/><path d="M128 72a56 56 0 1 0 56 56a56.06 56.06 0 0 0-56-56m0 96a40 40 0 1 1 40-40a40 40 0 0 1-40 40" fill-rule="evenodd"/></g></svg>
<span>近日点で速く遠日点で遅くなります — 同じ時間に同じ面積を払います</span>
</p>
<div class="or__field" id="field" role="img" aria-label="太陽系オーレリーの実験台">
<canvas class="or__cv" id="cv"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 태양계 오러리 — 캔버스 rAF(케플러 제2법칙: dtheta/dt = L / r^2 수치적분).
// CSS 는 무대·판만. 파스텔 팔레트(BD26).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-orange-deep;
@include stage($stage-ink);
.or {
--or-sun: #e7decf; // 태양 — 크림 골드 파스텔
--or-sun-glow: #f2ddb0;
--or-orbit: #3a3450; // 궤도 타원 — 어두운 회보라
--or-p0: #d8a8a0; // 수성 자리 — 로즈
--or-p1: #d8c8a0; // 금성 자리 — 샌드
--or-p2: #a0c0c8; // 지구 자리 — 스카이 파스텔
--or-p3: #c0a0b0; // 화성 자리 — 더스티 핑크
--or-p4: #b8b0c8; // 목성 자리 — 라일락 회
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.or__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;
}
.or__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.or__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);
}
.or__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.or__cv { display: block; width: 100%; height: 100%; }
@media (max-width: 340px) {
.or { gap: $sp-1; }
.or__field { height: 110px; }
.or__hint { font-size: 10px; }
}
// reduce — 루프가 안 돈다. 궤도와 행성 위치가 보이는 한 장만.
@media (prefers-reduced-motion: reduce) {
.or__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);
}
.or {
--or-sun: #e7decf;
--or-sun-glow: #f2ddb0;
--or-orbit: #3a3450;
--or-p0: #d8a8a0;
--or-p1: #d8c8a0;
--or-p2: #a0c0c8;
--or-p3: #c0a0b0;
--or-p4: #b8b0c8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.or__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;
}
.or__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.or__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);
}
.or__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.or__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.or {
gap: 4px;
}
.or__field {
height: 110px;
}
.or__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.or__field {
cursor: default !important;
}
}// 오러리(태양계 모형) — 각 행성은 타원 궤도 위에서 케플러 제2법칙(면적 속도 일정)을 따른다.
// 극좌표 타원 r(theta) = a(1-e^2)/(1+e*cos(theta)) 을 쓰고, 각속도를 dtheta/dt = L / r^2 로 매 프레임
// 수치적분한다(L 은 궤도별 각운동량 상수) — 근일점에서 빠르고 원일점에서 느려지는 진짜 이유다.
var root = document.getElementById('or');
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;
// 행성 — a(장반경 배율) e(이심률) L(각운동량 상수, 클수록 빠르게 돈다) theta0(시작각) hue(색 인덱스)
var PLANETS = [
{ a: 0.30, e: 0.10, L: 0.360, theta: 0.3, r: 3.6, hue: 0 },
{ a: 0.46, e: 0.05, L: 0.290, theta: 2.1, r: 4.6, hue: 1 },
{ a: 0.64, e: 0.16, L: 0.265, theta: 4.4, r: 4.2, hue: 2 },
{ a: 0.84, e: 0.08, L: 0.225, theta: 1.0, r: 5.4, hue: 3 },
{ a: 1.08, e: 0.22, L: 0.205, theta: 5.6, r: 6.2, hue: 4 }
];
// 각 행성의 궤적 잔상 — 최근 위치를 저장해 옅게 그린다(면적 속도 변화를 눈으로 보여줌)
for (var pi = 0; pi < PLANETS.length; pi++) PLANETS[pi].trail = [];
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);
}
// r(theta) — 초점이 원점(태양)인 타원의 극좌표식
function radiusAt(p, theta) {
return p.a * (1 - p.e * p.e) / (1 + p.e * Math.cos(theta));
}
function step(dt) {
for (var i = 0; i < PLANETS.length; i++) {
var p = PLANETS[i];
var r = radiusAt(p, p.theta);
var dtheta = p.L / (r * r); // 케플러 제2법칙: dA/dt = (1/2) r^2 dtheta/dt = const = L/2
p.theta = (p.theta + dtheta * dt) % TAU;
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
var cx = W / 2, cy = H / 2;
var scale = Math.min(W, H) * 0.46;
var cs = getComputedStyle(root);
var C_sun = cs.getPropertyValue('--or-sun').trim();
var C_sunGlow = cs.getPropertyValue('--or-sun-glow').trim();
var C_orbit = cs.getPropertyValue('--or-orbit').trim();
var colors = [
cs.getPropertyValue('--or-p0').trim(),
cs.getPropertyValue('--or-p1').trim(),
cs.getPropertyValue('--or-p2').trim(),
cs.getPropertyValue('--or-p3').trim(),
cs.getPropertyValue('--or-p4').trim()
];
// 궤도 타원 경로(고정, 은은하게)
ctx.strokeStyle = C_orbit;
ctx.lineWidth = 1;
for (var k = 0; k < PLANETS.length; k++) {
var pk = PLANETS[k];
ctx.beginPath();
for (var s = 0; s <= 96; s++) {
var th = (s / 96) * TAU;
var rr = radiusAt(pk, th);
var x = cx + Math.cos(th) * rr * scale;
var y = cy + Math.sin(th) * rr * scale * 0.62; // 살짝 눌러 원근감(탑뷰 기울임)
if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
// 태양
var sg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 16);
sg.addColorStop(0, C_sunGlow);
sg.addColorStop(1, C_sun);
ctx.globalAlpha = 0.22;
ctx.fillStyle = sg;
ctx.beginPath(); ctx.arc(cx, cy, 20, 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = C_sun;
ctx.beginPath(); ctx.arc(cx, cy, 7, 0, TAU); ctx.fill();
// 행성 + 궤적 + 쓸어간 면적 부채꼴(제2법칙 — 같은 시간에 같은 넓이)
for (var i = 0; i < PLANETS.length; i++) {
var p = PLANETS[i];
var r2 = radiusAt(p, p.theta);
var px = cx + Math.cos(p.theta) * r2 * scale;
var py = cy + Math.sin(p.theta) * r2 * scale * 0.62;
// 최근 각도 구간을 태양 중심 부채꼴로 옅게 채운다 — 근일점에서는 넓고 빠르게,
// 원일점에서는 좁고 느리게 쓸리는 것이 그대로 보인다
var SPAN = 1.35; // 부채꼴 각폭(rad)
ctx.beginPath();
ctx.moveTo(cx, cy);
for (var sa = 0; sa <= 24; sa++) {
var sth = p.theta - SPAN + (SPAN * 2) * (sa / 24);
var srr = radiusAt(p, sth);
var sx2 = cx + Math.cos(sth) * srr * scale;
var sy2 = cy + Math.sin(sth) * srr * scale * 0.62;
ctx.lineTo(sx2, sy2);
}
ctx.closePath();
ctx.globalAlpha = 0.18;
ctx.fillStyle = colors[p.hue];
ctx.fill();
ctx.globalAlpha = 1;
// 궤적 잔상
p.trail.push({ x: px, y: py });
if (p.trail.length > 14) p.trail.shift();
for (var t = 0; t < p.trail.length; t++) {
var a = (t / p.trail.length) * 0.5;
ctx.globalAlpha = a;
ctx.fillStyle = colors[p.hue];
ctx.beginPath();
ctx.arc(p.trail[t].x, p.trail[t].y, p.r * 0.62, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
// 행성 몸통 + 헤일로
ctx.globalAlpha = 0.2;
ctx.fillStyle = colors[p.hue];
ctx.beginPath();
ctx.arc(px, py, p.r * 2.6, 0, TAU);
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(px, py, p.r, 0, TAU);
ctx.fill();
}
}
function frame(now) {
var dt = last ? Math.min((now - last) / 1000, 0.05) : 0.016;
last = now;
step(dt);
draw();
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); draw(); });
fit();
if (reduce) {
draw();
} else {
raf = requestAnimationFrame(frame);
}// OrrerySolarSystem — vanilla 판(run/304/demo/07-orrery-solar-system/index.html) 의 React 포트.
// 각 행성은 타원 궤도 위에서 케플러 제2법칙(면적 속도 일정)을 따른다.
// r(theta) = a(1-e^2)/(1+e*cos(theta)), dtheta/dt = L / r^2 를 매 프레임 수치적분한다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 미리보기가 없다 — props 는 CSS 변수로 무대 격식에만 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./OrrerySolarSystem.module.scss";
export type OrrerySolarSystemProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Planet = { a: number; e: number; L: number; theta: number; r: number; hue: number; trail: { x: number; y: number }[] };
const TAU = Math.PI * 2;
const FIELD_LABEL = "태양계 오러리 실험판";
// 행성 — a(장반경 배율) e(이심률) L(각운동량 상수) theta(시작각) r(반지름 px) hue(색 인덱스)
const DEFS: { a: number; e: number; L: number; theta: number; r: number; hue: number }[] = [
{ a: 0.30, e: 0.10, L: 0.360, theta: 0.3, r: 3.6, hue: 0 },
{ a: 0.46, e: 0.05, L: 0.290, theta: 2.1, r: 4.6, hue: 1 },
{ a: 0.64, e: 0.16, L: 0.265, theta: 4.4, r: 4.2, hue: 2 },
{ a: 0.84, e: 0.08, L: 0.225, theta: 1.0, r: 5.4, hue: 3 },
{ a: 1.08, e: 0.22, L: 0.205, theta: 5.6, r: 6.2, hue: 4 }
];
// r(theta) — 초점이 원점(태양)인 타원의 극좌표식
function radiusAt(a: number, e: number, theta: number): number {
return a * (1 - e * e) / (1 + e * Math.cos(theta));
}
export default function OrrerySolarSystem({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c93000",
}: OrrerySolarSystemProps) {
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 planetsRef = useRef<Planet[]>([]) as Box<Planet[]>;
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);
}
function step(dt: number): void {
const planets = planetsRef.current;
for (let i = 0; i < planets.length; i++) {
const p = planets[i];
const r = radiusAt(p.a, p.e, p.theta);
const dtheta = p.L / (r * r); // 케플러 제2법칙: dA/dt = (1/2) r^2 dtheta/dt = const = L/2
p.theta = (p.theta + dtheta * dt) % TAU;
}
}
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 scale = Math.min(W, H) * 0.46;
const cs = getComputedStyle(root);
const C_sun = cs.getPropertyValue("--or-sun").trim();
const C_sunGlow = cs.getPropertyValue("--or-sun-glow").trim();
const C_orbit = cs.getPropertyValue("--or-orbit").trim();
const colors = [
cs.getPropertyValue("--or-p0").trim(),
cs.getPropertyValue("--or-p1").trim(),
cs.getPropertyValue("--or-p2").trim(),
cs.getPropertyValue("--or-p3").trim(),
cs.getPropertyValue("--or-p4").trim()
];
// 궤도 타원 경로(고정, 은은하게)
ctx.strokeStyle = C_orbit;
ctx.lineWidth = 1;
const planets = planetsRef.current;
for (let k = 0; k < planets.length; k++) {
const pk = planets[k];
ctx.beginPath();
for (let s = 0; s <= 96; s++) {
const th = (s / 96) * TAU;
const rr = radiusAt(pk.a, pk.e, th);
const x = cx + Math.cos(th) * rr * scale;
const y = cy + Math.sin(th) * rr * scale * 0.62; // 살짝 눌러 원근감(탑뷰 기울임)
if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
// 태양
const sg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 16);
sg.addColorStop(0, C_sunGlow);
sg.addColorStop(1, C_sun);
ctx.globalAlpha = 0.22;
ctx.fillStyle = sg;
ctx.beginPath(); ctx.arc(cx, cy, 20, 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = C_sun;
ctx.beginPath(); ctx.arc(cx, cy, 7, 0, TAU); ctx.fill();
// 행성 + 궤적 + 쓸어간 면적 부채꼴(제2법칙 — 같은 시간에 같은 넓이)
for (let i = 0; i < planets.length; i++) {
const p = planets[i];
const r2 = radiusAt(p.a, p.e, p.theta);
const px = cx + Math.cos(p.theta) * r2 * scale;
const py = cy + Math.sin(p.theta) * r2 * scale * 0.62;
// 최근 각도 구간을 태양 중심 부채꼴로 옅게 채운다
const SPAN = 1.35; // 부채꼴 각폭(rad)
ctx.beginPath();
ctx.moveTo(cx, cy);
for (let sa = 0; sa <= 24; sa++) {
const sth = p.theta - SPAN + (SPAN * 2) * (sa / 24);
const srr = radiusAt(p.a, p.e, sth);
const sx2 = cx + Math.cos(sth) * srr * scale;
const sy2 = cy + Math.sin(sth) * srr * scale * 0.62;
ctx.lineTo(sx2, sy2);
}
ctx.closePath();
ctx.globalAlpha = 0.18;
ctx.fillStyle = colors[p.hue];
ctx.fill();
ctx.globalAlpha = 1;
// 궤적 잔상
p.trail.push({ x: px, y: py });
if (p.trail.length > 14) p.trail.shift();
for (let t = 0; t < p.trail.length; t++) {
const a = (t / p.trail.length) * 0.5;
ctx.globalAlpha = a;
ctx.fillStyle = colors[p.hue];
ctx.beginPath();
ctx.arc(p.trail[t].x, p.trail[t].y, p.r * 0.62, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
// 행성 몸통 + 헤일로
ctx.globalAlpha = 0.2;
ctx.fillStyle = colors[p.hue];
ctx.beginPath();
ctx.arc(px, py, p.r * 2.6, 0, TAU);
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(px, py, p.r, 0, TAU);
ctx.fill();
}
}
function frame(now: number): void {
const dt = lastRef.current ? Math.min((now - lastRef.current) / 1000, 0.05) : 0.016;
lastRef.current = now;
step(dt);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
const planets = planetsRef.current;
planets.length = 0;
for (let i = 0; i < DEFS.length; i++) {
planets.push({ ...DEFS[i], trail: [] });
}
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"><path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24" opacity=".2"/><path d="M128 72a56 56 0 1 0 56 56a56.06 56.06 0 0 0-56-56m0 96a40 40 0 1 1 40-40a40 40 0 0 1-40 40" fillRule="evenodd"/></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>
);
}07 複数の惑星が回る太陽系モデル — 計測 累積面積 10.285% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 3D 地球儀サイト演出9選 — Three.jsなしのcanvas計算
このデモが属する記事
3D 地球儀サイト演出9選 — Three.jsなしのcanvas計算3d 地球儀のサイト演出は普通、Three.js に任せます。ここでは衛星軌道・航路曲線・昼夜の境界まで9種を、Three.jsなしで canvas 2D の計算だけで描き、いちばん広く動いた08粒子地球儀が26.98パーセント、06星空背 この記事のデモをすべて見る


