css sunset gradient 9가지 — 해가 낮아질수록 하늘과 바다가 따라 변한다
css sunset gradient 를 색 두 개를 섞은 배경으로만 만들면 해가 낮아져도 하늘은 그대로고, 바다엔 빛의 길이 없고, 구름 아랫면은 어두운 채다 — 01번·04번·05번이 계산으로 푼다. 해 높이 하나가 아홉 장면 전체를 다시 그린다.
해 지는 아홉 장면을 화면에서 바로 본다
01 ss-altitude-sky-ramp
02 ss-path-length-scatter-rows
03 ss-horizon-flatten-sun
04 ss-sea-glitter-path
05 ss-cloud-underlight-bands
06 ss-ridge-aerial-haze
07 ss-dusk-star-emergence
08 ss-drag-sun-time-scrub
09 ss-property-gradient-stops-loop
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 해 높이에 따라 하늘 색이 이어서 바뀌는 노을 배경
- 02 줄마다 빛이 지나온 길이로 색을 칠하는 대기 산란 하늘
- 03 지평선에 닿을수록 납작해지고 붉어지는 해
- 04 해 아래로 길게 이어지는 바다 윤슬 길
- 05 해가 낮을수록 아랫면이 먼저 붉어지는 구름 띠
- 06 멀수록 하늘색에 녹아드는 겹겹의 산 능선
- 07 하늘이 어두워지는 만큼 하나둘 드러나는 별
- 08 해를 끌어 하루를 되감는 노을 시간 스크러버
- 09 색 위치를 등록 속성으로 움직이는 순수 CSS 노을 그라디언트
css sunset gradient 9가지 — 해 높이·빛의 길이·반사를 계산해야 노을이 산다
01~08 은 캔버스 480×300 한 장에 상태 글자 한 줄과 버튼 하나가 붙은 꼴이고, 색 띠를 읽는 ramp·하늘 한 점을 칠하는 skyAt·바다를 까는 seaBase·빛번짐 glow 를 같은 글자로 나눠 쓴다. 장면마다 다른 것은 scene.draw 하나이며, 09 만 <script> 태그가 없는 순수 CSS 다.
입력은 해 높이 alt 하나다. 0 이면 해가 지평선에 걸리고 1 이면 높이 뜨며 음수는 지평선 아래이고, 화면 y 는 186 − alt × 150 이라 alt 0.5 는 111, 0.2 는 156, −0.12 는 204 에 놓인다. 01~07 의 자동 시연은 PERIOD_MS 4400 을 한 주기로 삼아 from 에서 to 까지 2.2초에 내려가고 2.2초에 올라오며, 08 은 AUTO_MS 7600 을 한 주기로 호를 오간다.
배경색 두 개를 섞는 방식이 노을을 못 만드는 까닭은 색이 해 높이를 모르기 때문이다. 이 편은 하늘 한 줄의 색을 해 높이와 줄 위치 두 값의 함수로 만들고 바다·구름·별이 같은 alt 를 받게 했으니, sunset background css 를 찾아 온 사람이 얻는 차이는 그라디언트 두 줄이 아니라 입력 하나가 층마다 다른 색 띠를 고른다는 점이다.
getImageData 는 아홉 파일 어디에도 없고 픽셀을 직접 쓰는 곳은 05 의 createImageData·putImageData 뿐이다. requestAnimationFrame·matchMedia·pointermove 는 01~08 의 여덟 파일에 있고 09 는 CSS 미디어 쿼리만 쓴다. Math.random 은 어디에도 없으며 별 자리는 시드를 고정한 mulberry32 로 뽑는다(07 은 시드 448, 08 은 시드 808).
css sunset beach 풍경을 기법별 코드로 본다
아래 조각은 각 파일에서 이어진 줄을 그대로 잘라 붙인 것이고 한 장면에 한두 조각씩 실었다. 하늘색은 01 의 ZENITH_RAMP·HORIZON_RAMP·SUN_RAMP 세 배열이 정하는데, sunset color css 를 손보려는 사람은 여기서 시작하면 된다. 처음부터 끝까지 이어진 파일은 이 글 아래쪽 압축 파일에 있다.
01해 높이에 따라 하늘 색이 이어서 바뀌는 노을 배경
skyAt 이 줄마다 v = 줄 ÷ 186 을 만들어 정점 색 띠와 지평선 색 띠에서 해 높이로 고른 두 색을 v 의 SKY_BLEND 1.7 제곱만큼 섞는다. 해가 내려오면 두 띠에서 고르는 자리가 같이 옮겨 가 하늘 전체가 한꺼번에 따뜻해지니 노을 히어로 배경에 얹기 좋다. 측정에서는 누적면적 74.2%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="해 높이에 따라 하늘 색이 이어서 바뀌는 노을 배경"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 기운다 — 금빛 오후</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
function main(rootEl, canvas, statusEl, btnEl, scene) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last = null;
let paused = false;
let manual = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function currentAlt() {
if (manual !== null) {
return manual;
}
if (reduce) {
return scene.from;
}
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show() {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a) {
manual = clamp(a, lo, hi);
if (!reduce) {
paused = true;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
manual = null;
}
setButton();
}
function altAtY(e) {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
t += dt;
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(altAtY(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(altAtY(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') {
d = KEY_ALT;
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
d = -KEY_ALT;
}
else {
return;
}
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SKY_ROW = 2;
const SUN_R = 16;
const SWATCH_Y0 = 52;
const SWATCH_Y1 = 232;
// 왼쪽 두 줄 — 정점 색 띠와 지평선 색 띠. 가로선이 지금 해 높이에서 고른 자리
function swatches(ctx, alt) {
for (let y = SWATCH_Y0; y < SWATCH_Y1; y += 2) {
const a = lerp(1, ALT_NIGHT, (y - SWATCH_Y0) / (SWATCH_Y1 - SWATCH_Y0));
ctx.fillStyle = css(ramp(ZENITH_RAMP, a), 1);
ctx.fillRect(14, y, 10, 3);
ctx.fillStyle = css(ramp(HORIZON_RAMP, a), 1);
ctx.fillRect(28, y, 10, 3);
}
const my = lerp(SWATCH_Y0, SWATCH_Y1, (1 - alt) / (1 - ALT_NIGHT));
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillRect(10, my - 1, 32, 2);
}
const scene = {
from: SUN_ALT,
to: -0.12,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
skyRows(ctx, alt, 0, HORIZON, SKY_ROW);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
sunDisc(ctx, SUN_X, sunY(alt), SUN_R, SUN_R, sc, 96);
ctx.restore();
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 40);
ctx.scale(1, 2.2);
glow(ctx, 0, 0, 48, sc, 0.3 * smooth((alt + 0.1) / 0.3));
ctx.restore();
swatches(ctx, alt);
}
};
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), scene);
})();import { useEffect, useRef } from "react";
import styles from "./SsAltitudeSkyRamp.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
interface Scene {
from: number;
to: number;
hi: number;
lo: number;
draw(ctx: Ctx, alt: number, t: number): void;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, scene: Scene): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last: number | null = null;
let paused = false;
let manual: number | null = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: HTMLElement, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function currentAlt(): number {
if (manual !== null) { return manual; }
if (reduce) { return scene.from; }
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show(): void {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a: number): void {
manual = clamp(a, lo, hi);
if (!reduce) { paused = true; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); }
else { rootEl.classList.add('is-demo'); manual = null; }
setButton();
}
function altAtY(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) { t += dt; show(); }
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(altAtY(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(altAtY(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') { d = KEY_ALT; }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { d = -KEY_ALT; }
else { return; }
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const SKY_ROW = 2;
const SUN_R = 16;
const SWATCH_Y0 = 52;
const SWATCH_Y1 = 232;
// 왼쪽 두 줄 — 정점 색 띠와 지평선 색 띠. 가로선이 지금 해 높이에서 고른 자리
function swatches(ctx: Ctx, alt: number): void {
for (let y = SWATCH_Y0; y < SWATCH_Y1; y += 2) {
const a = lerp(1, ALT_NIGHT, (y - SWATCH_Y0) / (SWATCH_Y1 - SWATCH_Y0));
ctx.fillStyle = css(ramp(ZENITH_RAMP, a), 1);
ctx.fillRect(14, y, 10, 3);
ctx.fillStyle = css(ramp(HORIZON_RAMP, a), 1);
ctx.fillRect(28, y, 10, 3);
}
const my = lerp(SWATCH_Y0, SWATCH_Y1, (1 - alt) / (1 - ALT_NIGHT));
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillRect(10, my - 1, 32, 2);
}
const scene: Scene = {
from: SUN_ALT,
to: -0.12,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx: Ctx, alt: number, t: number): void {
skyRows(ctx, alt, 0, HORIZON, SKY_ROW);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
sunDisc(ctx, SUN_X, sunY(alt), SUN_R, SUN_R, sc, 96);
ctx.restore();
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 40);
ctx.scale(1, 2.2);
glow(ctx, 0, 0, 48, sc, 0.3 * smooth((alt + 0.1) / 0.3));
ctx.restore();
swatches(ctx, alt);
}
};
export default function SsAltitudeSkyRamp() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, scene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="해 높이에 따라 하늘 색이 이어서 바뀌는 노을 배경" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 기운다 — 금빛 오후</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}01 · 해 높이에 따라 하늘 색이 이어서 바뀌는 노을 배경 — 측정 누적면적 74.222% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
const scene = {
from: SUN_ALT,
to: -0.12,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
skyRows(ctx, alt, 0, HORIZON, SKY_ROW);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
sunDisc(ctx, SUN_X, sunY(alt), SUN_R, SUN_R, sc, 96);
ctx.restore();
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 40);
ctx.scale(1, 2.2);
glow(ctx, 0, 0, 48, sc, 0.3 * smooth((alt + 0.1) / 0.3));
ctx.restore();
swatches(ctx, alt);
}
};
02줄마다 빛이 지나온 길이로 색을 칠하는 대기 산란 하늘
rowColor 가 가로줄마다 시선이 대기를 지나는 길이 1 ÷ (el + 0.1) 로 산란량을 구하고, 해 빛이 온 길이로 파랑부터 깎아 낮은 해일수록 붉은 빛이 줄 색에 남게 한다. 시선이 대기를 지나는 길이가 정점의 0.91 에서 지평선의 10 까지 급해지므로 시선 높이 0.1 이하에 색 변화가 몰리며, 노을 하늘 배경을 식 하나로 생성하려는 쪽에 맞다. 측정에서는 누적면적 73.9%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="줄마다 빛이 지나온 길이로 색을 칠하는 대기 산란 하늘"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 기운다 — 금빛 오후</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
function main(rootEl, canvas, statusEl, btnEl, scene) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last = null;
let paused = false;
let manual = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function currentAlt() {
if (manual !== null) {
return manual;
}
if (reduce) {
return scene.from;
}
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show() {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a) {
manual = clamp(a, lo, hi);
if (!reduce) {
paused = true;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
manual = null;
}
setButton();
}
function altAtY(e) {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
t += dt;
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(altAtY(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(altAtY(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') {
d = KEY_ALT;
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
d = -KEY_ALT;
}
else {
return;
}
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const AIR_DENSITY = 1.0;
const PATH_GAIN = 0.22;
const RED_KEEP = 0.55;
const ROW_STEP = 2;
const EXPOSURE = 6.8;
const SCATTER = [0.28, 0.7, 1.7];
const NIGHT_FLOOR = [0.03, 0.04, 0.11];
// 한 줄의 색 — el 은 그 줄을 보는 시선의 높이(1 = 정점, 0 = 지평선). 낮을수록 대기를 지나는 길이 길어진다
function rowColor(alt, el) {
const sunPath = 1 / (0.9 * Math.max(alt, 0) + 0.1);
const loss = [0.05 * (1 - RED_KEEP), 0.24, 0.42];
const day = smooth((alt + 0.16) / 0.34);
const rowPath = 1 / (el + 0.1);
const out = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const direct = Math.exp(-loss[c] * PATH_GAIN * sunPath * 4);
const seen = 1 - Math.exp(-SCATTER[c] * AIR_DENSITY * rowPath * 0.18);
const rad = direct * seen * (0.1 + 0.9 * day) + NIGHT_FLOOR[c] * (1 - day);
out[c] = 255 * (0.2 + 0.8 * (1 - Math.exp(-EXPOSURE * rad)));
}
return out;
}
const scene = {
from: SUN_ALT,
to: -0.12,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
for (let y = 0; y < HORIZON; y += ROW_STEP) {
ctx.fillStyle = css(rowColor(alt, 1 - y / HORIZON), 1);
ctx.fillRect(0, y, W, ROW_STEP + 1);
}
// 왼쪽 막대 — 줄마다 시선이 지나는 길이 (지평선에 가까울수록 길다)
for (let y = 52; y < HORIZON; y += 6) {
const path = 1 / ((1 - y / HORIZON) + 0.1);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillRect(14, y, 3 + path * 2.6, 3);
}
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, SUN_X, HORIZON, 140, rowColor(alt, 0), 0.4);
sunDisc(ctx, SUN_X, sunY(alt), 14, 14, sc, 70);
ctx.restore();
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 36);
ctx.scale(1, 2.2);
glow(ctx, 0, 0, 44, rowColor(alt, 0), 0.28 * smooth((alt + 0.1) / 0.3));
ctx.restore();
}
};
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), scene);
})();import { useEffect, useRef } from "react";
import styles from "./SsPathLengthScatterRows.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
interface Scene {
from: number;
to: number;
hi: number;
lo: number;
draw(ctx: Ctx, alt: number, t: number): void;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, scene: Scene): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last: number | null = null;
let paused = false;
let manual: number | null = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: HTMLElement, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function currentAlt(): number {
if (manual !== null) { return manual; }
if (reduce) { return scene.from; }
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show(): void {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a: number): void {
manual = clamp(a, lo, hi);
if (!reduce) { paused = true; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); }
else { rootEl.classList.add('is-demo'); manual = null; }
setButton();
}
function altAtY(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) { t += dt; show(); }
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(altAtY(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(altAtY(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') { d = KEY_ALT; }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { d = -KEY_ALT; }
else { return; }
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const AIR_DENSITY = 1.0;
const PATH_GAIN = 0.22;
const RED_KEEP = 0.55;
const ROW_STEP = 2;
const EXPOSURE = 6.8;
const SCATTER: RGB = [0.28, 0.7, 1.7];
const NIGHT_FLOOR: RGB = [0.03, 0.04, 0.11];
// 한 줄의 색 — el 은 그 줄을 보는 시선의 높이(1 = 정점, 0 = 지평선). 낮을수록 대기를 지나는 길이 길어진다
function rowColor(alt: number, el: number): RGB {
const sunPath = 1 / (0.9 * Math.max(alt, 0) + 0.1);
const loss: RGB = [0.05 * (1 - RED_KEEP), 0.24, 0.42];
const day = smooth((alt + 0.16) / 0.34);
const rowPath = 1 / (el + 0.1);
const out: RGB = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const direct = Math.exp(-loss[c] * PATH_GAIN * sunPath * 4);
const seen = 1 - Math.exp(-SCATTER[c] * AIR_DENSITY * rowPath * 0.18);
const rad = direct * seen * (0.1 + 0.9 * day) + NIGHT_FLOOR[c] * (1 - day);
out[c] = 255 * (0.2 + 0.8 * (1 - Math.exp(-EXPOSURE * rad)));
}
return out;
}
const scene: Scene = {
from: SUN_ALT,
to: -0.12,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx: Ctx, alt: number, t: number): void {
for (let y = 0; y < HORIZON; y += ROW_STEP) {
ctx.fillStyle = css(rowColor(alt, 1 - y / HORIZON), 1);
ctx.fillRect(0, y, W, ROW_STEP + 1);
}
// 왼쪽 막대 — 줄마다 시선이 지나는 길이 (지평선에 가까울수록 길다)
for (let y = 52; y < HORIZON; y += 6) {
const path = 1 / ((1 - y / HORIZON) + 0.1);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillRect(14, y, 3 + path * 2.6, 3);
}
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, SUN_X, HORIZON, 140, rowColor(alt, 0), 0.4);
sunDisc(ctx, SUN_X, sunY(alt), 14, 14, sc, 70);
ctx.restore();
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 36);
ctx.scale(1, 2.2);
glow(ctx, 0, 0, 44, rowColor(alt, 0), 0.28 * smooth((alt + 0.1) / 0.3));
ctx.restore();
}
};
export default function SsPathLengthScatterRows() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, scene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="줄마다 빛이 지나온 길이로 색을 칠하는 대기 산란 하늘" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 기운다 — 금빛 오후</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}02 · 줄마다 빛이 지나온 길이로 색을 칠하는 대기 산란 하늘 — 측정 누적면적 73.88% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 한 줄의 색 — el 은 그 줄을 보는 시선의 높이(1 = 정점, 0 = 지평선). 낮을수록 대기를 지나는 길이 길어진다
function rowColor(alt, el) {
const sunPath = 1 / (0.9 * Math.max(alt, 0) + 0.1);
const loss = [0.05 * (1 - RED_KEEP), 0.24, 0.42];
const day = smooth((alt + 0.16) / 0.34);
const rowPath = 1 / (el + 0.1);
const out = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const direct = Math.exp(-loss[c] * PATH_GAIN * sunPath * 4);
const seen = 1 - Math.exp(-SCATTER[c] * AIR_DENSITY * rowPath * 0.18);
const rad = direct * seen * (0.1 + 0.9 * day) + NIGHT_FLOOR[c] * (1 - day);
out[c] = 255 * (0.2 + 0.8 * (1 - Math.exp(-EXPOSURE * rad)));
}
return out;
}
03지평선에 닿을수록 납작해지고 붉어지는 해
해 원반은 세로 반지름이 FLATTEN_MAX 0.34 만큼 줄고, 가로 반지름은 코드에 적힌 리터럴 0.28 만큼 늘며, 색은 연노랑에서 붉은 산호빛으로 옮겨 가는데, 셋 다 low 한 값을 따른다. alt 0 에서 low 는 1 이라 세로 13.2·가로 25.6 의 납작한 해가 되고, 이 원반을 지평선 축으로 뒤집어 바다에 물결만큼 흔들어 비춘다. 측정에서는 누적면적 75.4%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="지평선에 닿을수록 납작해지고 붉어지는 해"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 기운다 — 금빛 오후</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
function main(rootEl, canvas, statusEl, btnEl, scene) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last = null;
let paused = false;
let manual = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function currentAlt() {
if (manual !== null) {
return manual;
}
if (reduce) {
return scene.from;
}
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show() {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a) {
manual = clamp(a, lo, hi);
if (!reduce) {
paused = true;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
manual = null;
}
setButton();
}
function altAtY(e) {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
t += dt;
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(altAtY(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(altAtY(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') {
d = KEY_ALT;
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
d = -KEY_ALT;
}
else {
return;
}
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const FLATTEN_MAX = 0.34;
const SUN_RADIUS = 20;
const HALO_SPREAD = 2.6;
const REDDEN_AT = 0.32;
const scene = {
from: 0.42,
to: -0.1,
hi: 0.8,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
skyRows(ctx, alt, 0, HORIZON, 2);
// 낮아질수록 0 에서 1 로 — 납작함·붉음·번짐이 모두 이 한 값을 따른다
const low = 1 - smooth(alt / REDDEN_AT);
const ry = SUN_RADIUS * (1 - FLATTEN_MAX * low);
const rx = SUN_RADIUS * (1 + 0.28 * low);
const col = mix([255, 246, 220], [255, 128, 112], low);
const sy = sunY(alt);
const halo = SUN_RADIUS * (2.2 + HALO_SPREAD * low);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, SUN_X, HORIZON, 170, col, 0.34 * low);
sunDisc(ctx, SUN_X, sy, rx, ry, col, halo);
ctx.restore();
seaBase(ctx, alt, t);
// 바다에 비친 해 — 지평선을 축으로 뒤집은 원반을 줄마다 잘라 물결만큼 흔든다
const yr = 2 * HORIZON - sy;
for (let y = HORIZON + 1; y < H; y += 2) {
const dist = (y - yr) / ry;
if (Math.abs(dist) < 1) {
const hw = rx * Math.sqrt(1 - dist * dist);
const shift = 5 * (0.3 + 0.7 * (y - HORIZON) / 60) * Math.sin(y * 0.5 + t * 0.003);
ctx.fillStyle = css(col, 0.62 * (1 - 0.5 * Math.min(1, (y - HORIZON) / 90)));
ctx.fillRect(SUN_X - hw + shift, y, hw * 2, 2);
}
}
ctx.save();
ctx.translate(SUN_X, HORIZON + 44);
ctx.scale(1, 2.4);
glow(ctx, 0, 0, halo, col, 0.26 * smooth((alt + 0.1) / 0.3));
ctx.restore();
}
};
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), scene);
})();import { useEffect, useRef } from "react";
import styles from "./SsHorizonFlattenSun.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
interface Scene {
from: number;
to: number;
hi: number;
lo: number;
draw(ctx: Ctx, alt: number, t: number): void;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, scene: Scene): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last: number | null = null;
let paused = false;
let manual: number | null = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: HTMLElement, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function currentAlt(): number {
if (manual !== null) { return manual; }
if (reduce) { return scene.from; }
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show(): void {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a: number): void {
manual = clamp(a, lo, hi);
if (!reduce) { paused = true; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); }
else { rootEl.classList.add('is-demo'); manual = null; }
setButton();
}
function altAtY(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) { t += dt; show(); }
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(altAtY(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(altAtY(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') { d = KEY_ALT; }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { d = -KEY_ALT; }
else { return; }
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const FLATTEN_MAX = 0.34;
const SUN_RADIUS = 20;
const HALO_SPREAD = 2.6;
const REDDEN_AT = 0.32;
const scene: Scene = {
from: 0.42,
to: -0.1,
hi: 0.8,
lo: ALT_NIGHT,
draw: function (ctx: Ctx, alt: number, t: number): void {
skyRows(ctx, alt, 0, HORIZON, 2);
// 낮아질수록 0 에서 1 로 — 납작함·붉음·번짐이 모두 이 한 값을 따른다
const low = 1 - smooth(alt / REDDEN_AT);
const ry = SUN_RADIUS * (1 - FLATTEN_MAX * low);
const rx = SUN_RADIUS * (1 + 0.28 * low);
const col = mix([255, 246, 220], [255, 128, 112], low);
const sy = sunY(alt);
const halo = SUN_RADIUS * (2.2 + HALO_SPREAD * low);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, SUN_X, HORIZON, 170, col, 0.34 * low);
sunDisc(ctx, SUN_X, sy, rx, ry, col, halo);
ctx.restore();
seaBase(ctx, alt, t);
// 바다에 비친 해 — 지평선을 축으로 뒤집은 원반을 줄마다 잘라 물결만큼 흔든다
const yr = 2 * HORIZON - sy;
for (let y = HORIZON + 1; y < H; y += 2) {
const dist = (y - yr) / ry;
if (Math.abs(dist) < 1) {
const hw = rx * Math.sqrt(1 - dist * dist);
const shift = 5 * (0.3 + 0.7 * (y - HORIZON) / 60) * Math.sin(y * 0.5 + t * 0.003);
ctx.fillStyle = css(col, 0.62 * (1 - 0.5 * Math.min(1, (y - HORIZON) / 90)));
ctx.fillRect(SUN_X - hw + shift, y, hw * 2, 2);
}
}
ctx.save();
ctx.translate(SUN_X, HORIZON + 44);
ctx.scale(1, 2.4);
glow(ctx, 0, 0, halo, col, 0.26 * smooth((alt + 0.1) / 0.3));
ctx.restore();
}
};
export default function SsHorizonFlattenSun() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, scene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="지평선에 닿을수록 납작해지고 붉어지는 해" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 기운다 — 금빛 오후</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}03 · 지평선에 닿을수록 납작해지고 붉어지는 해 — 측정 누적면적 75.381% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 낮아질수록 0 에서 1 로 — 납작함·붉음·번짐이 모두 이 한 값을 따른다
const low = 1 - smooth(alt / REDDEN_AT);
const ry = SUN_RADIUS * (1 - FLATTEN_MAX * low);
const rx = SUN_RADIUS * (1 + 0.28 * low);
const col = mix([255, 246, 220], [255, 128, 112], low);
const sy = sunY(alt);
const halo = SUN_RADIUS * (2.2 + HALO_SPREAD * low);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, SUN_X, HORIZON, 170, col, 0.34 * low);
sunDisc(ctx, SUN_X, sy, rx, ry, col, halo);
ctx.restore();
// 바다에 비친 해 — 지평선을 축으로 뒤집은 원반을 줄마다 잘라 물결만큼 흔든다
const yr = 2 * HORIZON - sy;
for (let y = HORIZON + 1; y < H; y += 2) {
const dist = (y - yr) / ry;
if (Math.abs(dist) < 1) {
const hw = rx * Math.sqrt(1 - dist * dist);
const shift = 5 * (0.3 + 0.7 * (y - HORIZON) / 60) * Math.sin(y * 0.5 + t * 0.003);
ctx.fillStyle = css(col, 0.62 * (1 - 0.5 * Math.min(1, (y - HORIZON) / 90)));
ctx.fillRect(SUN_X - hw + shift, y, hw * 2, 2);
}
}
04해 아래로 길게 이어지는 바다 윤슬 길
glitterRoad 가 바다를 2px 줄 57개로 나눠 줄마다 파도로 흔들린 중심을 구하고, 그 중심 둘레 폭 안의 조각 아홉 개를 vnoise 로 켜고 꺼서 해 아래에 끊어진 빛의 길을 만든다. 폭의 반은 줄 위치 d 가 0 인 지평선에서 7.7px, d 가 1 인 맨 아래에서 31.9px 이라 원근이 붙고 바다 풍경 배경으로 바로 쓸 수 있다. 측정에서는 누적면적 74.9%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="해 아래로 길게 이어지는 바다 윤슬 길"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 낮다 — 노을</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
const WAVE_AMP = 14;
const GLITTER_WIDTH = 22;
const ROW_JITTER = 0.7;
// 바다 윤슬 길 — 수면 가로줄마다 파도로 흔들린 위치를 구하고, 해 아래 폭 안의 조각만 밝게 칠한다
function glitterRoad(ctx, alt, cx0, t, col) {
const vis = smooth((alt + 0.08) / 0.22);
if (vis <= 0.01) {
return;
}
const lit = mix(col, [255, 255, 255], 0.4);
for (let y = HORIZON + 1; y < H; y += 2) {
const d = (y - HORIZON) / (H - HORIZON);
const jit = ROW_JITTER * (hash2(y, 11) - 0.5) * 2;
const shift = WAVE_AMP * (0.18 + 0.82 * d) * (Math.sin(y * 0.31 + t * 0.0021) + 0.6 * Math.sin(y * 0.77 - t * 0.0033 + jit * 3) + jit * 0.5);
const half = GLITTER_WIDTH * (0.35 + 1.1 * d);
const seg = 9;
const segW = half * 2 / seg;
for (let s = 0; s < seg; s += 1) {
const u = (s + 0.5) / seg * 2 - 1;
const on = smooth((vnoise(s * 1.7 + y * 0.11, t * 0.0016 + y * 0.05) - 0.42) * 5);
const a = vis * (1 - u * u) * on * (0.95 - 0.4 * d);
if (a > 0.02) {
ctx.fillStyle = css(lit, a);
ctx.fillRect(cx0 + shift - half + s * segW, y, segW * 0.9, 2);
}
}
}
}
function main(rootEl, canvas, statusEl, btnEl, scene) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last = null;
let paused = false;
let manual = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function currentAlt() {
if (manual !== null) {
return manual;
}
if (reduce) {
return scene.from;
}
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show() {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a) {
manual = clamp(a, lo, hi);
if (!reduce) {
paused = true;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
manual = null;
}
setButton();
}
function altAtY(e) {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
t += dt;
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(altAtY(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(altAtY(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') {
d = KEY_ALT;
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
d = -KEY_ALT;
}
else {
return;
}
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const scene = {
from: 0.3,
to: -0.1,
hi: 0.8,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
skyRows(ctx, alt, 0, HORIZON, 2);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
sunDisc(ctx, SUN_X, sunY(alt), 16, 16, sc, 90);
ctx.restore();
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 54);
ctx.scale(0.6, 2.8);
glow(ctx, 0, 0, 60, sc, 0.3 * smooth((alt + 0.1) / 0.3));
ctx.restore();
glitterRoad(ctx, alt, SUN_X, t, sc);
}
};
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), scene);
})();import { useEffect, useRef } from "react";
import styles from "./SsSeaGlitterPath.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
const WAVE_AMP = 14;
const GLITTER_WIDTH = 22;
const ROW_JITTER = 0.7;
// 바다 윤슬 길 — 수면 가로줄마다 파도로 흔들린 위치를 구하고, 해 아래 폭 안의 조각만 밝게 칠한다
function glitterRoad(ctx: Ctx, alt: number, cx0: number, t: number, col: RGB): void {
const vis = smooth((alt + 0.08) / 0.22);
if (vis <= 0.01) { return; }
const lit = mix(col, [255, 255, 255], 0.4);
for (let y = HORIZON + 1; y < H; y += 2) {
const d = (y - HORIZON) / (H - HORIZON);
const jit = ROW_JITTER * (hash2(y, 11) - 0.5) * 2;
const shift = WAVE_AMP * (0.18 + 0.82 * d) * (Math.sin(y * 0.31 + t * 0.0021) + 0.6 * Math.sin(y * 0.77 - t * 0.0033 + jit * 3) + jit * 0.5);
const half = GLITTER_WIDTH * (0.35 + 1.1 * d);
const seg = 9;
const segW = half * 2 / seg;
for (let s = 0; s < seg; s += 1) {
const u = (s + 0.5) / seg * 2 - 1;
const on = smooth((vnoise(s * 1.7 + y * 0.11, t * 0.0016 + y * 0.05) - 0.42) * 5);
const a = vis * (1 - u * u) * on * (0.95 - 0.4 * d);
if (a > 0.02) {
ctx.fillStyle = css(lit, a);
ctx.fillRect(cx0 + shift - half + s * segW, y, segW * 0.9, 2);
}
}
}
}
interface Scene {
from: number;
to: number;
hi: number;
lo: number;
draw(ctx: Ctx, alt: number, t: number): void;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, scene: Scene): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last: number | null = null;
let paused = false;
let manual: number | null = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: HTMLElement, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function currentAlt(): number {
if (manual !== null) { return manual; }
if (reduce) { return scene.from; }
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show(): void {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a: number): void {
manual = clamp(a, lo, hi);
if (!reduce) { paused = true; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); }
else { rootEl.classList.add('is-demo'); manual = null; }
setButton();
}
function altAtY(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) { t += dt; show(); }
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(altAtY(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(altAtY(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') { d = KEY_ALT; }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { d = -KEY_ALT; }
else { return; }
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const scene: Scene = {
from: 0.3,
to: -0.1,
hi: 0.8,
lo: ALT_NIGHT,
draw: function (ctx: Ctx, alt: number, t: number): void {
skyRows(ctx, alt, 0, HORIZON, 2);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
sunDisc(ctx, SUN_X, sunY(alt), 16, 16, sc, 90);
ctx.restore();
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 54);
ctx.scale(0.6, 2.8);
glow(ctx, 0, 0, 60, sc, 0.3 * smooth((alt + 0.1) / 0.3));
ctx.restore();
glitterRoad(ctx, alt, SUN_X, t, sc);
}
};
export default function SsSeaGlitterPath() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, scene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="해 아래로 길게 이어지는 바다 윤슬 길" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 낮다 — 노을</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}04 · 해 아래로 길게 이어지는 바다 윤슬 길 — 측정 누적면적 74.935% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 바다 윤슬 길 — 수면 가로줄마다 파도로 흔들린 위치를 구하고, 해 아래 폭 안의 조각만 밝게 칠한다
function glitterRoad(ctx, alt, cx0, t, col) {
const vis = smooth((alt + 0.08) / 0.22);
if (vis <= 0.01) {
return;
}
const lit = mix(col, [255, 255, 255], 0.4);
for (let y = HORIZON + 1; y < H; y += 2) {
const d = (y - HORIZON) / (H - HORIZON);
const jit = ROW_JITTER * (hash2(y, 11) - 0.5) * 2;
const shift = WAVE_AMP * (0.18 + 0.82 * d) * (Math.sin(y * 0.31 + t * 0.0021) + 0.6 * Math.sin(y * 0.77 - t * 0.0033 + jit * 3) + jit * 0.5);
const half = GLITTER_WIDTH * (0.35 + 1.1 * d);
const seg = 9;
const segW = half * 2 / seg;
for (let s = 0; s < seg; s += 1) {
const u = (s + 0.5) / seg * 2 - 1;
const on = smooth((vnoise(s * 1.7 + y * 0.11, t * 0.0016 + y * 0.05) - 0.42) * 5);
const a = vis * (1 - u * u) * on * (0.95 - 0.4 * d);
if (a > 0.02) {
ctx.fillStyle = css(lit, a);
ctx.fillRect(cx0 + shift - half + s * segW, y, segW * 0.9, 2);
}
}
}
}
05해가 낮을수록 아랫면이 먼저 붉어지는 구름 띠
160×64 ImageData 한 장을 만들어 픽셀마다 fbm 으로 밀도를 구하고, 세로 네 층마다 underlit 이 불빛 받는 정도를 따로 재서 아랫층부터 붉게 물들인다. alt 0.3 에서는 아래 두 층이, alt 0 에서는 아래 세 층이 붉고 맨 위 층은 LIGHT_REACH 0.9 가 1 에 못 미쳐 본래 색에 남는다. 측정에서는 누적면적 75.3%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="해가 낮을수록 아랫면이 먼저 붉어지는 구름 띠"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 기운다 — 금빛 오후</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
const CLOUD_LAYERS = 4;
const UNDERLIT_BIAS = 0.7;
const LIGHT_REACH = 0.9;
// 구름 한 층(0 = 맨 위, 1 = 맨 아래)이 해 불빛을 받는 정도 — 해가 낮을수록 빛이 닿는 깊이가 위로 올라온다
function underlit(layerK, alt) {
const reach = (1 - smooth(alt / 0.55)) * LIGHT_REACH;
const u = Math.pow(1 - layerK, 1 + UNDERLIT_BIAS);
return smooth((reach - u) / 0.4);
}
function cloudTone(layerK, alt) {
const dusk = 1 - smooth((alt + 0.1) / 0.5);
const body = mix([246, 246, 253], [124, 120, 172], dusk);
const shaded = mix(body, [body[0] * 0.9, body[1] * 0.9, body[2] * 0.96], layerK * 0.4);
const flame = mix([255, 204, 156], [255, 132, 128], layerK);
return mix(shaded, flame, underlit(layerK, alt));
}
function main(rootEl, canvas, statusEl, btnEl, scene) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last = null;
let paused = false;
let manual = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function currentAlt() {
if (manual !== null) {
return manual;
}
if (reduce) {
return scene.from;
}
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show() {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a) {
manual = clamp(a, lo, hi);
if (!reduce) {
paused = true;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
manual = null;
}
setButton();
}
function altAtY(e) {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
t += dt;
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(altAtY(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(altAtY(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') {
d = KEY_ALT;
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
d = -KEY_ALT;
}
else {
return;
}
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const DRIFT_BAND = 0.00022;
const CLOUD_X = 160;
const CLOUD_Y = 64;
const CLOUD_TOP = 24;
const CLOUD_BOTTOM = 152;
function makeScene() {
const off = document.createElement('canvas');
off.width = CLOUD_X;
off.height = CLOUD_Y;
const octx0 = off.getContext('2d');
if (!octx0) {
throw new Error('canvas');
}
const octx = octx0;
const img = octx.createImageData(CLOUD_X, CLOUD_Y);
const px = img.data;
// 구름 한 장 — 픽셀마다 밀도를 잡음으로 구하고, 층 번호로 불빛 색을 고른다
function paintClouds(alt, t) {
const tones = [];
for (let l = 0; l < CLOUD_LAYERS; l += 1) {
tones.push(cloudTone(l / (CLOUD_LAYERS - 1), alt));
}
for (let j = 0; j < CLOUD_Y; j += 1) {
const rowK = j / (CLOUD_Y - 1);
const layer = Math.min(CLOUD_LAYERS - 1, Math.floor(rowK * CLOUD_LAYERS));
const tone = tones[layer];
const envelope = Math.pow(Math.sin(Math.PI * (0.06 + 0.88 * rowK)), 0.8);
for (let i = 0; i < CLOUD_X; i += 1) {
const nx = i / CLOUD_X * 3.4 + t * DRIFT_BAND * (0.5 + rowK);
const d = fbm(nx, rowK * 3.3 + 2.1) * envelope;
const a = smooth((d - 0.3) / 0.22);
const k = 0.9 + 0.2 * d;
const o = (j * CLOUD_X + i) * 4;
px[o] = Math.min(255, tone[0] * k);
px[o + 1] = Math.min(255, tone[1] * k);
px[o + 2] = Math.min(255, tone[2] * k);
px[o + 3] = 255 * a;
}
}
octx.putImageData(img, 0, 0);
}
return {
from: SUN_ALT,
to: -0.08,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
skyRows(ctx, alt, 0, HORIZON, 2);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
sunDisc(ctx, SUN_X, sunY(alt), 16, 16, sc, 96);
ctx.restore();
paintClouds(alt, t);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, 0, CLOUD_TOP, W, CLOUD_BOTTOM - CLOUD_TOP);
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 40);
ctx.scale(1, 2.2);
glow(ctx, 0, 0, 46, sc, 0.28 * smooth((alt + 0.1) / 0.3));
ctx.restore();
// 오른쪽 층 눈금 — 층마다 지금 불빛을 받는 정도
for (let l = 0; l < CLOUD_LAYERS; l += 1) {
const k = l / (CLOUD_LAYERS - 1);
ctx.fillStyle = css(cloudTone(k, alt), 1);
ctx.fillRect(14, 54 + l * 14, 18, 10);
}
}
};
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene());
})();import { useEffect, useRef } from "react";
import styles from "./SsCloudUnderlightBands.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
const CLOUD_LAYERS = 4;
const UNDERLIT_BIAS = 0.7;
const LIGHT_REACH = 0.9;
// 구름 한 층(0 = 맨 위, 1 = 맨 아래)이 해 불빛을 받는 정도 — 해가 낮을수록 빛이 닿는 깊이가 위로 올라온다
function underlit(layerK: number, alt: number): number {
const reach = (1 - smooth(alt / 0.55)) * LIGHT_REACH;
const u = Math.pow(1 - layerK, 1 + UNDERLIT_BIAS);
return smooth((reach - u) / 0.4);
}
function cloudTone(layerK: number, alt: number): RGB {
const dusk = 1 - smooth((alt + 0.1) / 0.5);
const body = mix([246, 246, 253], [124, 120, 172], dusk);
const shaded = mix(body, [body[0] * 0.9, body[1] * 0.9, body[2] * 0.96], layerK * 0.4);
const flame = mix([255, 204, 156], [255, 132, 128], layerK);
return mix(shaded, flame, underlit(layerK, alt));
}
interface Scene {
from: number;
to: number;
hi: number;
lo: number;
draw(ctx: Ctx, alt: number, t: number): void;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, scene: Scene): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last: number | null = null;
let paused = false;
let manual: number | null = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: HTMLElement, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function currentAlt(): number {
if (manual !== null) { return manual; }
if (reduce) { return scene.from; }
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show(): void {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a: number): void {
manual = clamp(a, lo, hi);
if (!reduce) { paused = true; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); }
else { rootEl.classList.add('is-demo'); manual = null; }
setButton();
}
function altAtY(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) { t += dt; show(); }
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(altAtY(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(altAtY(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') { d = KEY_ALT; }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { d = -KEY_ALT; }
else { return; }
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const DRIFT_BAND = 0.00022;
const CLOUD_X = 160;
const CLOUD_Y = 64;
const CLOUD_TOP = 24;
const CLOUD_BOTTOM = 152;
function makeScene(): Scene {
const off = document.createElement('canvas');
off.width = CLOUD_X;
off.height = CLOUD_Y;
const octx0 = off.getContext('2d');
if (!octx0) { throw new Error('canvas'); }
const octx: Ctx = octx0;
const img = octx.createImageData(CLOUD_X, CLOUD_Y);
const px = img.data;
// 구름 한 장 — 픽셀마다 밀도를 잡음으로 구하고, 층 번호로 불빛 색을 고른다
function paintClouds(alt: number, t: number): void {
const tones: RGB[] = [];
for (let l = 0; l < CLOUD_LAYERS; l += 1) { tones.push(cloudTone(l / (CLOUD_LAYERS - 1), alt)); }
for (let j = 0; j < CLOUD_Y; j += 1) {
const rowK = j / (CLOUD_Y - 1);
const layer = Math.min(CLOUD_LAYERS - 1, Math.floor(rowK * CLOUD_LAYERS));
const tone = tones[layer];
const envelope = Math.pow(Math.sin(Math.PI * (0.06 + 0.88 * rowK)), 0.8);
for (let i = 0; i < CLOUD_X; i += 1) {
const nx = i / CLOUD_X * 3.4 + t * DRIFT_BAND * (0.5 + rowK);
const d = fbm(nx, rowK * 3.3 + 2.1) * envelope;
const a = smooth((d - 0.3) / 0.22);
const k = 0.9 + 0.2 * d;
const o = (j * CLOUD_X + i) * 4;
px[o] = Math.min(255, tone[0] * k);
px[o + 1] = Math.min(255, tone[1] * k);
px[o + 2] = Math.min(255, tone[2] * k);
px[o + 3] = 255 * a;
}
}
octx.putImageData(img, 0, 0);
}
return {
from: SUN_ALT,
to: -0.08,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx: Ctx, alt: number, t: number): void {
skyRows(ctx, alt, 0, HORIZON, 2);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
sunDisc(ctx, SUN_X, sunY(alt), 16, 16, sc, 96);
ctx.restore();
paintClouds(alt, t);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, 0, CLOUD_TOP, W, CLOUD_BOTTOM - CLOUD_TOP);
seaBase(ctx, alt, t);
ctx.save();
ctx.translate(SUN_X, HORIZON + 40);
ctx.scale(1, 2.2);
glow(ctx, 0, 0, 46, sc, 0.28 * smooth((alt + 0.1) / 0.3));
ctx.restore();
// 오른쪽 층 눈금 — 층마다 지금 불빛을 받는 정도
for (let l = 0; l < CLOUD_LAYERS; l += 1) {
const k = l / (CLOUD_LAYERS - 1);
ctx.fillStyle = css(cloudTone(k, alt), 1);
ctx.fillRect(14, 54 + l * 14, 18, 10);
}
}
};
}
export default function SsCloudUnderlightBands() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, makeScene());
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="해가 낮을수록 아랫면이 먼저 붉어지는 구름 띠" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 기운다 — 금빛 오후</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}05 · 해가 낮을수록 아랫면이 먼저 붉어지는 구름 띠 — 측정 누적면적 75.346% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 구름 한 층(0 = 맨 위, 1 = 맨 아래)이 해 불빛을 받는 정도 — 해가 낮을수록 빛이 닿는 깊이가 위로 올라온다
function underlit(layerK, alt) {
const reach = (1 - smooth(alt / 0.55)) * LIGHT_REACH;
const u = Math.pow(1 - layerK, 1 + UNDERLIT_BIAS);
return smooth((reach - u) / 0.4);
}
function cloudTone(layerK, alt) {
const dusk = 1 - smooth((alt + 0.1) / 0.5);
const body = mix([246, 246, 253], [124, 120, 172], dusk);
const shaded = mix(body, [body[0] * 0.9, body[1] * 0.9, body[2] * 0.96], layerK * 0.4);
const flame = mix([255, 204, 156], [255, 132, 128], layerK);
return mix(shaded, flame, underlit(layerK, alt));
}
function paintClouds(alt, t) {
const tones = [];
for (let l = 0; l < CLOUD_LAYERS; l += 1) {
tones.push(cloudTone(l / (CLOUD_LAYERS - 1), alt));
}
for (let j = 0; j < CLOUD_Y; j += 1) {
const rowK = j / (CLOUD_Y - 1);
const layer = Math.min(CLOUD_LAYERS - 1, Math.floor(rowK * CLOUD_LAYERS));
const tone = tones[layer];
const envelope = Math.pow(Math.sin(Math.PI * (0.06 + 0.88 * rowK)), 0.8);
for (let i = 0; i < CLOUD_X; i += 1) {
const nx = i / CLOUD_X * 3.4 + t * DRIFT_BAND * (0.5 + rowK);
const d = fbm(nx, rowK * 3.3 + 2.1) * envelope;
const a = smooth((d - 0.3) / 0.22);
const k = 0.9 + 0.2 * d;
const o = (j * CLOUD_X + i) * 4;
px[o] = Math.min(255, tone[0] * k);
px[o + 1] = Math.min(255, tone[1] * k);
px[o + 2] = Math.min(255, tone[2] * k);
px[o + 3] = 255 * a;
}
}
octx.putImageData(img, 0, 0);
}
06멀수록 하늘색에 녹아드는 겹겹의 산 능선
ridgeTop 이 능선 다섯 겹의 높이를 fbm 으로 구하고, 먼 겹일수록 지금 하늘색을 더 섞어 안개 비율을 0.80·0.57·0.35·0.15·0 으로 낮춘다. 어두운 쪽으로 섞는 비율은 0.25 에서 0.77 로 올라 가까운 능선이 짙고 먼 능선이 하늘에 녹으니 풍경 일러스트 히어로에 깊이를 준다. 측정에서는 누적면적 75.1%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="멀수록 하늘색에 녹아드는 겹겹의 산 능선"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 기운다 — 금빛 오후</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
function main(rootEl, canvas, statusEl, btnEl, scene) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last = null;
let paused = false;
let manual = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function currentAlt() {
if (manual !== null) {
return manual;
}
if (reduce) {
return scene.from;
}
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show() {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a) {
manual = clamp(a, lo, hi);
if (!reduce) {
paused = true;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
manual = null;
}
setButton();
}
function altAtY(e) {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
t += dt;
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(altAtY(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(altAtY(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') {
d = KEY_ALT;
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
d = -KEY_ALT;
}
else {
return;
}
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const RIDGE_LAYERS = 5;
const HAZE_STRENGTH = 0.8;
const NEAR_DARKEN = 0.52;
const RIDGE_ROUGH = 1.0;
const RIDGE_STEP = 2;
const RIDGE_GROUND = 268;
// 능선 하나의 높이 — 층 번호 i(0 = 가장 먼 것)마다 기준선·진폭·주파수·흐름 속도가 다르다
function ridgeTop(i, x, t) {
const k = i / (RIDGE_LAYERS - 1);
const base = lerp(176, RIDGE_GROUND, Math.pow(k, 0.9));
const amp = (16 + 26 * k) * RIDGE_ROUGH;
const freq = 0.0105 + 0.004 * k;
return base - amp * fbm(x * freq + i * 31.7 + t * 0.00002 * (1 + 2.4 * k), i * 5.31);
}
const scene = {
from: SUN_ALT,
to: -0.08,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
skyRows(ctx, alt, 0, HORIZON + 6, 2);
const sc = ramp(SUN_RAMP, alt);
sunDisc(ctx, SUN_X, sunY(alt), 16, 16, sc, 100);
const hazeSky = skyAt(alt, 0.95);
const body = skyAt(alt, 0.5);
for (let i = 0; i < RIDGE_LAYERS; i += 1) {
const k = i / (RIDGE_LAYERS - 1);
// 가까울수록 어두운 쪽으로, 멀수록 지금 하늘 색 쪽으로 — 두 값 모두 능선 번호 하나에서 나온다
const solid = mix(body, [34, 38, 80], 0.25 + NEAR_DARKEN * k);
const haze = HAZE_STRENGTH * Math.pow(1 - k, 1.2);
const top = mix(solid, hazeSky, haze);
const bottom = mix(solid, hazeSky, haze * 0.55);
const g = ctx.createLinearGradient(0, 130, 0, H);
g.addColorStop(0, css(top, 1));
g.addColorStop(1, css([bottom[0] * 0.82, bottom[1] * 0.82, bottom[2] * 0.86], 1));
ctx.fillStyle = g;
for (let x = 0; x < W; x += RIDGE_STEP) {
const y = ridgeTop(i, x, t);
ctx.fillRect(x, y, RIDGE_STEP + 1, H - y);
}
}
}
};
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), scene);
})();import { useEffect, useRef } from "react";
import styles from "./SsRidgeAerialHaze.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
interface Scene {
from: number;
to: number;
hi: number;
lo: number;
draw(ctx: Ctx, alt: number, t: number): void;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, scene: Scene): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last: number | null = null;
let paused = false;
let manual: number | null = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: HTMLElement, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function currentAlt(): number {
if (manual !== null) { return manual; }
if (reduce) { return scene.from; }
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show(): void {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a: number): void {
manual = clamp(a, lo, hi);
if (!reduce) { paused = true; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); }
else { rootEl.classList.add('is-demo'); manual = null; }
setButton();
}
function altAtY(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) { t += dt; show(); }
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(altAtY(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(altAtY(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') { d = KEY_ALT; }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { d = -KEY_ALT; }
else { return; }
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const RIDGE_LAYERS = 5;
const HAZE_STRENGTH = 0.8;
const NEAR_DARKEN = 0.52;
const RIDGE_ROUGH = 1.0;
const RIDGE_STEP = 2;
const RIDGE_GROUND = 268;
// 능선 하나의 높이 — 층 번호 i(0 = 가장 먼 것)마다 기준선·진폭·주파수·흐름 속도가 다르다
function ridgeTop(i: number, x: number, t: number): number {
const k = i / (RIDGE_LAYERS - 1);
const base = lerp(176, RIDGE_GROUND, Math.pow(k, 0.9));
const amp = (16 + 26 * k) * RIDGE_ROUGH;
const freq = 0.0105 + 0.004 * k;
return base - amp * fbm(x * freq + i * 31.7 + t * 0.00002 * (1 + 2.4 * k), i * 5.31);
}
const scene: Scene = {
from: SUN_ALT,
to: -0.08,
hi: 1,
lo: ALT_NIGHT,
draw: function (ctx: Ctx, alt: number, t: number): void {
skyRows(ctx, alt, 0, HORIZON + 6, 2);
const sc = ramp(SUN_RAMP, alt);
sunDisc(ctx, SUN_X, sunY(alt), 16, 16, sc, 100);
const hazeSky = skyAt(alt, 0.95);
const body = skyAt(alt, 0.5);
for (let i = 0; i < RIDGE_LAYERS; i += 1) {
const k = i / (RIDGE_LAYERS - 1);
// 가까울수록 어두운 쪽으로, 멀수록 지금 하늘 색 쪽으로 — 두 값 모두 능선 번호 하나에서 나온다
const solid = mix(body, [34, 38, 80], 0.25 + NEAR_DARKEN * k);
const haze = HAZE_STRENGTH * Math.pow(1 - k, 1.2);
const top = mix(solid, hazeSky, haze);
const bottom = mix(solid, hazeSky, haze * 0.55);
const g = ctx.createLinearGradient(0, 130, 0, H);
g.addColorStop(0, css(top, 1));
g.addColorStop(1, css([bottom[0] * 0.82, bottom[1] * 0.82, bottom[2] * 0.86], 1));
ctx.fillStyle = g;
for (let x = 0; x < W; x += RIDGE_STEP) {
const y = ridgeTop(i, x, t);
ctx.fillRect(x, y, RIDGE_STEP + 1, H - y);
}
}
}
};
export default function SsRidgeAerialHaze() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, scene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="멀수록 하늘색에 녹아드는 겹겹의 산 능선" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 기운다 — 금빛 오후</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}06 · 멀수록 하늘색에 녹아드는 겹겹의 산 능선 — 측정 누적면적 75.139% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 능선 하나의 높이 — 층 번호 i(0 = 가장 먼 것)마다 기준선·진폭·주파수·흐름 속도가 다르다
function ridgeTop(i, x, t) {
const k = i / (RIDGE_LAYERS - 1);
const base = lerp(176, RIDGE_GROUND, Math.pow(k, 0.9));
const amp = (16 + 26 * k) * RIDGE_ROUGH;
const freq = 0.0105 + 0.004 * k;
return base - amp * fbm(x * freq + i * 31.7 + t * 0.00002 * (1 + 2.4 * k), i * 5.31);
}
for (let i = 0; i < RIDGE_LAYERS; i += 1) {
const k = i / (RIDGE_LAYERS - 1);
// 가까울수록 어두운 쪽으로, 멀수록 지금 하늘 색 쪽으로 — 두 값 모두 능선 번호 하나에서 나온다
const solid = mix(body, [34, 38, 80], 0.25 + NEAR_DARKEN * k);
const haze = HAZE_STRENGTH * Math.pow(1 - k, 1.2);
const top = mix(solid, hazeSky, haze);
const bottom = mix(solid, hazeSky, haze * 0.55);
const g = ctx.createLinearGradient(0, 130, 0, H);
g.addColorStop(0, css(top, 1));
g.addColorStop(1, css([bottom[0] * 0.82, bottom[1] * 0.82, bottom[2] * 0.86], 1));
ctx.fillStyle = g;
for (let x = 0; x < W; x += RIDGE_STEP) {
const y = ridgeTop(i, x, t);
ctx.fillRect(x, y, RIDGE_STEP + 1, H - y);
}
}
07하늘이 어두워지는 만큼 하나둘 드러나는 별
별 90개마다 밝기 b 와 보이기 시작하는 하늘 휘도 문턱 thr 를 0.2~0.62 사이에서 따로 두고, 그 자리 하늘이 문턱 아래로 어두워지면 smooth 로 서서히 드러낸다. 밝은 별이 먼저 나오고 흐린 별이 뒤따라서 보이는 별 수가 alt 0.14 에서 4, 0 에서 13, −0.16 에서 48 로 늘며, 해질녘에서 밤으로 넘어가는 전환 화면에 맞다. 측정에서는 누적면적 74.2%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="하늘이 어두워지는 만큼 하나둘 드러나는 별"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 낮다 — 노을</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
function main(rootEl, canvas, statusEl, btnEl, scene) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last = null;
let paused = false;
let manual = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function currentAlt() {
if (manual !== null) {
return manual;
}
if (reduce) {
return scene.from;
}
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show() {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a) {
manual = clamp(a, lo, hi);
if (!reduce) {
paused = true;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
manual = null;
}
setButton();
}
function altAtY(e) {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
t += dt;
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(altAtY(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(altAtY(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') {
d = KEY_ALT;
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
d = -KEY_ALT;
}
else {
return;
}
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const STAR_COUNT = 90;
const VISIBLE_MIN = 0.2;
const VISIBLE_MAX = 0.62;
const MAG_SPREAD = 2.2;
const TWINKLE_BAND = 0.22;
function makeStars() {
const rnd = mulberry32(448);
const out = [];
for (let i = 0; i < STAR_COUNT; i += 1) {
const b = Math.pow(rnd(), MAG_SPREAD);
out.push({ x: 8 + rnd() * (W - 16), y: 6 + Math.pow(rnd(), 0.9) * (HORIZON - 26), b: b, thr: lerp(VISIBLE_MIN, VISIBLE_MAX, b), phase: rnd() * PI2 });
}
return out;
}
const stars = makeStars();
const scene = {
from: 0.14,
to: -0.16,
hi: 0.3,
lo: ALT_NIGHT,
draw: function (ctx, alt, t) {
skyRows(ctx, alt, 0, HORIZON, 2);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, SUN_X, HORIZON, 150, sc, 0.45 * smooth((alt + 0.18) / 0.3));
sunDisc(ctx, SUN_X, sunY(alt), 14, 14, sc, 70);
ctx.restore();
seaBase(ctx, alt, t);
let shown = 0;
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
// 별 하나의 기준 — 그 자리 하늘 밝기가 문턱 아래로 내려가야 보이기 시작한다
const sky = luma(skyAt(alt, s.y / HORIZON));
const vis = smooth((s.thr - sky) / 0.07);
if (vis > 0.02) {
shown += 1;
const a = vis * (1 - TWINKLE_BAND * (0.5 + 0.5 * Math.sin(t * 0.004 + s.phase)));
const r = 0.7 + 1.5 * s.b;
ctx.fillStyle = 'rgba(255,252,240,' + a.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(s.x, s.y, r, 0, PI2);
ctx.fill();
if (s.b > 0.45) {
glow(ctx, s.x, s.y, 7 + 8 * s.b, [255, 244, 220], 0.35 * a);
}
if (s.b > 0.35) {
const ry = HORIZON + (HORIZON - s.y) * 0.55;
if (ry < H) {
ctx.fillStyle = 'rgba(255,248,230,' + (0.5 * a).toFixed(3) + ')';
ctx.fillRect(s.x + 3 * Math.sin(t * 0.003 + s.phase), ry, 2 + 3 * s.b, 1.5);
}
}
}
}
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 96, 30, 88, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('stars ' + shown + '/' + STAR_COUNT, W - 14, 42);
}
};
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), scene);
})();import { useEffect, useRef } from "react";
import styles from "./SsDuskStarEmergence.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
interface Scene {
from: number;
to: number;
hi: number;
lo: number;
draw(ctx: Ctx, alt: number, t: number): void;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, scene: Scene): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const lo = Math.min(scene.lo, scene.hi);
const hi = Math.max(scene.lo, scene.hi);
let t = 0;
let last: number | null = null;
let paused = false;
let manual: number | null = null;
let preset = 0;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: HTMLElement, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function currentAlt(): number {
if (manual !== null) { return manual; }
if (reduce) { return scene.from; }
return lerp(scene.from, scene.to, 0.5 - 0.5 * Math.cos(PI2 * t / PERIOD_MS));
}
function show(): void {
const a = currentAlt();
scene.draw(ctx, a, t);
gauge(ctx, a, lo, hi);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function takeOver(a: number): void {
manual = clamp(a, lo, hi);
if (!reduce) { paused = true; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % 4;
manual = lerp(scene.from, scene.to, preset / 3);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); }
else { rootEl.classList.add('is-demo'); manual = null; }
setButton();
}
function altAtY(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
const py = clamp((e.clientY - r.top) * H / r.height, 0, H);
return lerp(hi, lo, py / H);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) { t += dt; show(); }
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(altAtY(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(altAtY(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') { d = KEY_ALT; }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { d = -KEY_ALT; }
else { return; }
e.preventDefault();
takeOver(currentAlt() + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const STAR_COUNT = 90;
const VISIBLE_MIN = 0.2;
const VISIBLE_MAX = 0.62;
const MAG_SPREAD = 2.2;
const TWINKLE_BAND = 0.22;
interface Star { x: number; y: number; b: number; thr: number; phase: number; }
function makeStars(): Star[] {
const rnd = mulberry32(448);
const out: Star[] = [];
for (let i = 0; i < STAR_COUNT; i += 1) {
const b = Math.pow(rnd(), MAG_SPREAD);
out.push({ x: 8 + rnd() * (W - 16), y: 6 + Math.pow(rnd(), 0.9) * (HORIZON - 26), b: b, thr: lerp(VISIBLE_MIN, VISIBLE_MAX, b), phase: rnd() * PI2 });
}
return out;
}
const stars: Star[] = makeStars();
const scene: Scene = {
from: 0.14,
to: -0.16,
hi: 0.3,
lo: ALT_NIGHT,
draw: function (ctx: Ctx, alt: number, t: number): void {
skyRows(ctx, alt, 0, HORIZON, 2);
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, SUN_X, HORIZON, 150, sc, 0.45 * smooth((alt + 0.18) / 0.3));
sunDisc(ctx, SUN_X, sunY(alt), 14, 14, sc, 70);
ctx.restore();
seaBase(ctx, alt, t);
let shown = 0;
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
// 별 하나의 기준 — 그 자리 하늘 밝기가 문턱 아래로 내려가야 보이기 시작한다
const sky = luma(skyAt(alt, s.y / HORIZON));
const vis = smooth((s.thr - sky) / 0.07);
if (vis > 0.02) {
shown += 1;
const a = vis * (1 - TWINKLE_BAND * (0.5 + 0.5 * Math.sin(t * 0.004 + s.phase)));
const r = 0.7 + 1.5 * s.b;
ctx.fillStyle = 'rgba(255,252,240,' + a.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(s.x, s.y, r, 0, PI2);
ctx.fill();
if (s.b > 0.45) {
glow(ctx, s.x, s.y, 7 + 8 * s.b, [255, 244, 220], 0.35 * a);
}
if (s.b > 0.35) {
const ry = HORIZON + (HORIZON - s.y) * 0.55;
if (ry < H) {
ctx.fillStyle = 'rgba(255,248,230,' + (0.5 * a).toFixed(3) + ')';
ctx.fillRect(s.x + 3 * Math.sin(t * 0.003 + s.phase), ry, 2 + 3 * s.b, 1.5);
}
}
}
}
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 96, 30, 88, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('stars ' + shown + '/' + STAR_COUNT, W - 14, 42);
}
};
export default function SsDuskStarEmergence() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl, scene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="하늘이 어두워지는 만큼 하나둘 드러나는 별" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 낮다 — 노을</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}07 · 하늘이 어두워지는 만큼 하나둘 드러나는 별 — 측정 누적면적 74.174% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function makeStars() {
const rnd = mulberry32(448);
const out = [];
for (let i = 0; i < STAR_COUNT; i += 1) {
const b = Math.pow(rnd(), MAG_SPREAD);
out.push({ x: 8 + rnd() * (W - 16), y: 6 + Math.pow(rnd(), 0.9) * (HORIZON - 26), b: b, thr: lerp(VISIBLE_MIN, VISIBLE_MAX, b), phase: rnd() * PI2 });
}
return out;
}
for (let i = 0; i < stars.length; i += 1) {
const s = stars[i];
// 별 하나의 기준 — 그 자리 하늘 밝기가 문턱 아래로 내려가야 보이기 시작한다
const sky = luma(skyAt(alt, s.y / HORIZON));
const vis = smooth((s.thr - sky) / 0.07);
if (vis > 0.02) {
shown += 1;
const a = vis * (1 - TWINKLE_BAND * (0.5 + 0.5 * Math.sin(t * 0.004 + s.phase)));
const r = 0.7 + 1.5 * s.b;
ctx.fillStyle = 'rgba(255,252,240,' + a.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(s.x, s.y, r, 0, PI2);
ctx.fill();
if (s.b > 0.45) {
glow(ctx, s.x, s.y, 7 + 8 * s.b, [255, 244, 220], 0.35 * a);
}
if (s.b > 0.35) {
const ry = HORIZON + (HORIZON - s.y) * 0.55;
if (ry < H) {
ctx.fillStyle = 'rgba(255,248,230,' + (0.5 * a).toFixed(3) + ')';
ctx.fillRect(s.x + 3 * Math.sin(t * 0.003 + s.phase), ry, 2 + 3 * s.b, 1.5);
}
}
}
}
08해를 끌어 하루를 되감는 노을 시간 스크러버
arcPoint 한 함수가 호 위 위치 s(0~1)에서 해 좌표와 alt 를 내는데, 하늘·별·구름 색·바다·윤슬 길은 alt 를 받고 해의 x 는 빛무리와 윤슬 중심에만 쓰이며, 화면 s 는 목표 s 를 DRAG_SMOOTH 0.22 비율로 따라온다. 시각 표시는 6시에 12시간 × s 를 더한 값이라 s 0.5 가 h 12:00 이 되고, 호 아래를 누르면 가까운 끝으로 붙어 시간대 미리보기 컨트롤로 쓸 수 있다. 측정에서는 누적면적 75.5%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="해를 끌어 하루를 되감는 노을 시간 스크러버"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">해가 졌다 — 땅거미</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt) {
if (alt > 0.6) {
return TXT_HIGH;
}
if (alt > 0.3) {
return TXT_GOLD;
}
if (alt > 0.08) {
return TXT_GLOW;
}
if (alt > -0.06) {
return TXT_TOUCH;
}
return TXT_DUSK;
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c) { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x, y) {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops, a) {
if (a <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt, v) {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt) { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt) { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx, alt, y0, y1, step) {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx, alt, t) {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx, x, y, rx, ry, c, halo) {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx, alt, lo, hi) {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
const CLOUD_LAYERS = 4;
const UNDERLIT_BIAS = 0.7;
const LIGHT_REACH = 0.9;
// 구름 한 층(0 = 맨 위, 1 = 맨 아래)이 해 불빛을 받는 정도 — 해가 낮을수록 빛이 닿는 깊이가 위로 올라온다
function underlit(layerK, alt) {
const reach = (1 - smooth(alt / 0.55)) * LIGHT_REACH;
const u = Math.pow(1 - layerK, 1 + UNDERLIT_BIAS);
return smooth((reach - u) / 0.4);
}
function cloudTone(layerK, alt) {
const dusk = 1 - smooth((alt + 0.1) / 0.5);
const body = mix([246, 246, 253], [124, 120, 172], dusk);
const shaded = mix(body, [body[0] * 0.9, body[1] * 0.9, body[2] * 0.96], layerK * 0.4);
const flame = mix([255, 204, 156], [255, 132, 128], layerK);
return mix(shaded, flame, underlit(layerK, alt));
}
const WAVE_AMP = 14;
const GLITTER_WIDTH = 22;
const ROW_JITTER = 0.7;
// 바다 윤슬 길 — 수면 가로줄마다 파도로 흔들린 위치를 구하고, 해 아래 폭 안의 조각만 밝게 칠한다
function glitterRoad(ctx, alt, cx0, t, col) {
const vis = smooth((alt + 0.08) / 0.22);
if (vis <= 0.01) {
return;
}
const lit = mix(col, [255, 255, 255], 0.4);
for (let y = HORIZON + 1; y < H; y += 2) {
const d = (y - HORIZON) / (H - HORIZON);
const jit = ROW_JITTER * (hash2(y, 11) - 0.5) * 2;
const shift = WAVE_AMP * (0.18 + 0.82 * d) * (Math.sin(y * 0.31 + t * 0.0021) + 0.6 * Math.sin(y * 0.77 - t * 0.0033 + jit * 3) + jit * 0.5);
const half = GLITTER_WIDTH * (0.35 + 1.1 * d);
const seg = 9;
const segW = half * 2 / seg;
for (let s = 0; s < seg; s += 1) {
const u = (s + 0.5) / seg * 2 - 1;
const on = smooth((vnoise(s * 1.7 + y * 0.11, t * 0.0016 + y * 0.05) - 0.42) * 5);
const a = vis * (1 - u * u) * on * (0.95 - 0.4 * d);
if (a > 0.02) {
ctx.fillStyle = css(lit, a);
ctx.fillRect(cx0 + shift - half + s * segW, y, segW * 0.9, 2);
}
}
}
}
const ARC_RADIUS = 176;
const ARC_CX = 240;
const ARC_CY = 204;
const DRAG_SMOOTH = 0.22;
const DAY_CLAMP = 0.015;
const SCRUB_STEP = 0.04;
const AUTO_MS = 7600;
const SUN_PX = 15;
const SCRUB_STARS = 64;
const REST_S = 0.8;
function makeStars() {
const rnd = mulberry32(808);
const out = [];
for (let i = 0; i < SCRUB_STARS; i += 1) {
const b = Math.pow(rnd(), 2.2);
out.push({ x: 8 + rnd() * (W - 16), y: 6 + rnd() * (HORIZON - 40), b: b, thr: lerp(0.2, 0.62, b) });
}
return out;
}
const stars = makeStars();
// 호 위의 위치 s(0 = 왼쪽 끝, 1 = 오른쪽 끝) -> 해의 화면 좌표와 높이
function arcPoint(s) {
const x = ARC_CX - ARC_RADIUS * Math.cos(Math.PI * s);
const y = ARC_CY - ARC_RADIUS * Math.sin(Math.PI * s);
return { x: x, y: y, alt: (HORIZON - y) / SUN_RISE };
}
// 포인터 위치 -> 호 위 s. 호 아래로 내려가면 가까운 끝으로 붙는다
function sFromPointer(px, py) {
const ang = Math.atan2(ARC_CY - py, px - ARC_CX);
if (ang < 0) {
return px < ARC_CX ? DAY_CLAMP : 1 - DAY_CLAMP;
}
return clamp(1 - ang / Math.PI, DAY_CLAMP, 1 - DAY_CLAMP);
}
function clockText(s) {
const mins = Math.round((6 + 12 * s) * 60);
const hh = Math.floor(mins / 60);
const mm = mins % 60;
return 'h ' + (hh < 10 ? '0' : '') + hh + ':' + (mm < 10 ? '0' : '') + mm;
}
function drawScene(ctx, s, t) {
const p = arcPoint(s);
const alt = p.alt;
skyRows(ctx, alt, 0, HORIZON, 2);
for (let i = 0; i < stars.length; i += 1) {
const st = stars[i];
const vis = smooth((st.thr - luma(skyAt(alt, st.y / HORIZON))) / 0.07);
if (vis > 0.02) {
ctx.fillStyle = 'rgba(255,252,240,' + (vis * (0.78 + 0.22 * Math.sin(t * 0.004 + st.x))).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(st.x, st.y, 0.7 + 1.4 * st.b, 0, PI2);
ctx.fill();
}
}
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, p.x, HORIZON, 150, sc, 0.4 * smooth((alt + 0.18) / 0.3) * (1 - smooth(alt / 0.5)));
sunDisc(ctx, p.x, p.y, SUN_PX, SUN_PX, sc, 84);
ctx.restore();
// 구름 두 띠 — 한 값(alt)으로 윗면은 하늘 색에 남고 아랫면부터 불빛이 번진다
for (let b = 0; b < 2; b += 1) {
const cy = 62 + b * 40;
const g = ctx.createLinearGradient(0, cy - 14, 0, cy + 14);
g.addColorStop(0, css(cloudTone(0.05, alt), 0.95));
g.addColorStop(0.5, css(cloudTone(0.5, alt), 0.95));
g.addColorStop(1, css(cloudTone(1, alt), 0.95));
ctx.fillStyle = g;
const rnd = mulberry32(31 + b);
for (let k = 0; k < 7; k += 1) {
const cx = ((rnd() * W + t * (0.006 + 0.004 * b)) % (W + 120)) - 60;
ctx.beginPath();
ctx.ellipse(cx, cy + (rnd() - 0.5) * 8, 30 + rnd() * 22, 8 + rnd() * 6, 0, 0, PI2);
ctx.fill();
}
}
seaBase(ctx, alt, t);
glitterRoad(ctx, alt, p.x, t, sc);
// 해가 지나는 호와 손잡이
for (let a = 0; a <= 1.001; a += 0.025) {
const q = arcPoint(a);
if (q.y < H - 4) {
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillRect(q.x - 1, q.y - 1, 2, 2);
}
}
ctx.beginPath();
ctx.arc(p.x, p.y, SUN_PX + 6, 0, PI2);
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 66, 8, 58, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText(clockText(s), W - 14, 20);
return alt;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
const presets = [0.2, 0.5, 0.8, 0.97];
let t = 0;
let last = null;
let paused = false;
let manual = false;
let target = reduce ? REST_S : 1 - DAY_CLAMP;
let shownS = target;
let preset = 2;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners = [];
function listen(el, type, fn) {
el.addEventListener(type, fn);
cleaners.push(function () { el.removeEventListener(type, fn); });
}
function show() {
const a = drawScene(ctx, shownS, t);
const s = labelOf(a);
if (s !== lastLabel) {
lastLabel = s;
statusEl.textContent = s;
}
}
function setButton() { btnEl.textContent = reduce ? '다음 장면' : (paused || manual ? '재생' : '멈춤'); }
// 사용자가 끌거나 키를 누르면 자동 시연을 멈추고 그 값으로 간다 (움직임 줄이기에서도 입력은 즉시 반영)
function takeOver(s) {
target = clamp(s, DAY_CLAMP, 1 - DAY_CLAMP);
manual = true;
if (reduce) {
shownS = target;
}
else {
paused = false;
rootEl.classList.remove('is-demo');
setButton();
}
show();
}
function onButton() {
if (reduce) {
preset = (preset + 1) % presets.length;
target = presets[preset];
shownS = target;
show();
return;
}
if (manual) {
manual = false;
t = 0;
rootEl.classList.add('is-demo');
paused = false;
}
else {
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
}
setButton();
}
function toS(e) {
const r = canvas.getBoundingClientRect();
return sFromPointer((e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
if (!manual) {
t += dt;
target = 0.5 + (0.5 - DAY_CLAMP) * Math.cos(PI2 * t / AUTO_MS);
}
shownS += (target - shownS) * (1 - Math.pow(1 - DRAG_SMOOTH, dt / 16.7));
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) { pressed = true; takeOver(toS(e)); });
listen(canvas, 'pointermove', function (e) { if (pressed) {
takeOver(toS(e));
} });
listen(canvas, 'pointerup', function () { pressed = false; });
listen(canvas, 'pointercancel', function () { pressed = false; });
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let d = 0;
if (k === 'ArrowRight' || k === 'ArrowUp') {
d = SCRUB_STEP;
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
d = -SCRUB_STEP;
}
else {
return;
}
e.preventDefault();
takeOver(target + d);
});
setButton();
show();
if (!reduce) {
raf = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./SsDragSunTimeScrub.module.scss";
type RGB = [number, number, number];
type Stop = [number, RGB];
type Ctx = CanvasRenderingContext2D;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const HORIZON = 186;
const SUN_RISE = 150;
const SUN_X = 312;
const SUN_ALT = 0.5;
const ALT_NIGHT = -0.15;
const PERIOD_MS = 4400;
const KEY_ALT = 0.05;
const SKY_BLEND = 1.7;
// 해 높이(0 = 지평선, 1 = 높이 뜸) 순서대로 고른 색 띠 — 정점 쪽, 지평선 쪽, 해 원반
const ZENITH_RAMP: Stop[] = [[-0.15, [40, 44, 96]], [0, [92, 92, 156]], [0.12, [128, 126, 182]], [0.3, [150, 160, 214]], [0.6, [150, 192, 238]], [1, [138, 190, 242]]];
const HORIZON_RAMP: Stop[] = [[-0.15, [110, 104, 160]], [0, [238, 134, 142]], [0.12, [255, 168, 146]], [0.3, [255, 208, 166]], [0.6, [240, 226, 222]], [1, [206, 230, 248]]];
const SUN_RAMP: Stop[] = [[-0.15, [238, 128, 134]], [0, [255, 150, 126]], [0.15, [255, 190, 140]], [0.4, [255, 226, 176]], [1, [255, 246, 222]]];
const TXT_HIGH = '해가 높다 — 낮 하늘';
const TXT_GOLD = '해가 기운다 — 금빛 오후';
const TXT_GLOW = '해가 낮다 — 노을';
const TXT_TOUCH = '해가 지평선에 닿는다 — 붉은 띠';
const TXT_DUSK = '해가 졌다 — 땅거미';
function labelOf(alt: number): string {
if (alt > 0.6) { return TXT_HIGH; }
if (alt > 0.3) { return TXT_GOLD; }
if (alt > 0.08) { return TXT_GLOW; }
if (alt > -0.06) { return TXT_TOUCH; }
return TXT_DUSK;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function css(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
function luma(c: RGB): number { return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function fbm(x: number, y: number): number {
return vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7.3, y * 2.1 + 3.1) * 0.3 + vnoise(x * 4.3 + 1.7, y * 4.3 + 9.4) * 0.15;
}
function ramp(stops: Stop[], a: number): RGB {
if (a <= stops[0][0]) { return stops[0][1]; }
for (let i = 1; i < stops.length; i += 1) {
if (a <= stops[i][0]) {
const p = stops[i - 1];
const q = stops[i];
return mix(p[1], q[1], (a - p[0]) / (q[0] - p[0]));
}
}
return stops[stops.length - 1][1];
}
// 하늘 한 점의 색 — v 는 0(정점)에서 1(지평선)
function skyAt(alt: number, v: number): RGB {
return mix(ramp(ZENITH_RAMP, alt), ramp(HORIZON_RAMP, alt), Math.pow(clamp(v, 0, 1), SKY_BLEND));
}
function sunY(alt: number): number { return HORIZON - alt * SUN_RISE; }
function seaDeep(alt: number): RGB { return mix([84, 128, 170], [36, 48, 98], clamp((0.3 - alt) / 0.4, 0, 1)); }
function glow(ctx: Ctx, x: number, y: number, r: number, c: RGB, a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, css(c, a));
g.addColorStop(1, css(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function skyRows(ctx: Ctx, alt: number, y0: number, y1: number, step: number): void {
for (let y = y0; y < y1; y += step) {
ctx.fillStyle = css(skyAt(alt, (y - y0) / (y1 - y0)), 1);
ctx.fillRect(0, y, W, step + 1);
}
}
// 바다 바탕 — 지평선 하늘색을 받아 아래로 갈수록 깊어지고, 줄마다 잔물결 밝기가 흔들린다
function seaBase(ctx: Ctx, alt: number, t: number): void {
const top = mix(skyAt(alt, 1), seaDeep(alt), 0.35);
const deep = seaDeep(alt);
for (let y = HORIZON; y < H; y += 3) {
const k = (y - HORIZON) / (H - HORIZON);
const c = mix(top, deep, Math.pow(k, 0.7));
const r = 1 + 0.05 * Math.sin(y * 0.8 + t * 0.0018 + k * 6);
ctx.fillStyle = css([c[0] * r, c[1] * r, c[2] * r], 1);
ctx.fillRect(0, y, W, 4);
}
}
function sunDisc(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: RGB, halo: number): void {
glow(ctx, x, y, halo, c, 0.5);
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, PI2);
ctx.fillStyle = css(c, 1);
ctx.fill();
}
// 오른쪽 가장자리의 해 높이 눈금 — 입력이 얼마인지 눈에 보이게
function gauge(ctx: Ctx, alt: number, lo: number, hi: number): void {
const y0 = 46;
const y1 = 236;
const gy = lerp(y1, y0, (alt - lo) / (hi - lo));
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(W - 14, y0, 2, y1 - y0);
ctx.beginPath();
ctx.arc(W - 13, clamp(gy, y0, y1), 5, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 76, 8, 68, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText('alt ' + alt.toFixed(2), W - 14, 20);
}
const CLOUD_LAYERS = 4;
const UNDERLIT_BIAS = 0.7;
const LIGHT_REACH = 0.9;
// 구름 한 층(0 = 맨 위, 1 = 맨 아래)이 해 불빛을 받는 정도 — 해가 낮을수록 빛이 닿는 깊이가 위로 올라온다
function underlit(layerK: number, alt: number): number {
const reach = (1 - smooth(alt / 0.55)) * LIGHT_REACH;
const u = Math.pow(1 - layerK, 1 + UNDERLIT_BIAS);
return smooth((reach - u) / 0.4);
}
function cloudTone(layerK: number, alt: number): RGB {
const dusk = 1 - smooth((alt + 0.1) / 0.5);
const body = mix([246, 246, 253], [124, 120, 172], dusk);
const shaded = mix(body, [body[0] * 0.9, body[1] * 0.9, body[2] * 0.96], layerK * 0.4);
const flame = mix([255, 204, 156], [255, 132, 128], layerK);
return mix(shaded, flame, underlit(layerK, alt));
}
const WAVE_AMP = 14;
const GLITTER_WIDTH = 22;
const ROW_JITTER = 0.7;
// 바다 윤슬 길 — 수면 가로줄마다 파도로 흔들린 위치를 구하고, 해 아래 폭 안의 조각만 밝게 칠한다
function glitterRoad(ctx: Ctx, alt: number, cx0: number, t: number, col: RGB): void {
const vis = smooth((alt + 0.08) / 0.22);
if (vis <= 0.01) { return; }
const lit = mix(col, [255, 255, 255], 0.4);
for (let y = HORIZON + 1; y < H; y += 2) {
const d = (y - HORIZON) / (H - HORIZON);
const jit = ROW_JITTER * (hash2(y, 11) - 0.5) * 2;
const shift = WAVE_AMP * (0.18 + 0.82 * d) * (Math.sin(y * 0.31 + t * 0.0021) + 0.6 * Math.sin(y * 0.77 - t * 0.0033 + jit * 3) + jit * 0.5);
const half = GLITTER_WIDTH * (0.35 + 1.1 * d);
const seg = 9;
const segW = half * 2 / seg;
for (let s = 0; s < seg; s += 1) {
const u = (s + 0.5) / seg * 2 - 1;
const on = smooth((vnoise(s * 1.7 + y * 0.11, t * 0.0016 + y * 0.05) - 0.42) * 5);
const a = vis * (1 - u * u) * on * (0.95 - 0.4 * d);
if (a > 0.02) {
ctx.fillStyle = css(lit, a);
ctx.fillRect(cx0 + shift - half + s * segW, y, segW * 0.9, 2);
}
}
}
}
const ARC_RADIUS = 176;
const ARC_CX = 240;
const ARC_CY = 204;
const DRAG_SMOOTH = 0.22;
const DAY_CLAMP = 0.015;
const SCRUB_STEP = 0.04;
const AUTO_MS = 7600;
const SUN_PX = 15;
const SCRUB_STARS = 64;
const REST_S = 0.8;
interface Star { x: number; y: number; b: number; thr: number; }
function makeStars(): Star[] {
const rnd = mulberry32(808);
const out: Star[] = [];
for (let i = 0; i < SCRUB_STARS; i += 1) {
const b = Math.pow(rnd(), 2.2);
out.push({ x: 8 + rnd() * (W - 16), y: 6 + rnd() * (HORIZON - 40), b: b, thr: lerp(0.2, 0.62, b) });
}
return out;
}
const stars: Star[] = makeStars();
// 호 위의 위치 s(0 = 왼쪽 끝, 1 = 오른쪽 끝) -> 해의 화면 좌표와 높이
function arcPoint(s: number): { x: number; y: number; alt: number } {
const x = ARC_CX - ARC_RADIUS * Math.cos(Math.PI * s);
const y = ARC_CY - ARC_RADIUS * Math.sin(Math.PI * s);
return { x: x, y: y, alt: (HORIZON - y) / SUN_RISE };
}
// 포인터 위치 -> 호 위 s. 호 아래로 내려가면 가까운 끝으로 붙는다
function sFromPointer(px: number, py: number): number {
const ang = Math.atan2(ARC_CY - py, px - ARC_CX);
if (ang < 0) { return px < ARC_CX ? DAY_CLAMP : 1 - DAY_CLAMP; }
return clamp(1 - ang / Math.PI, DAY_CLAMP, 1 - DAY_CLAMP);
}
function clockText(s: number): string {
const mins = Math.round((6 + 12 * s) * 60);
const hh = Math.floor(mins / 60);
const mm = mins % 60;
return 'h ' + (hh < 10 ? '0' : '') + hh + ':' + (mm < 10 ? '0' : '') + mm;
}
function drawScene(ctx: Ctx, s: number, t: number): number {
const p = arcPoint(s);
const alt = p.alt;
skyRows(ctx, alt, 0, HORIZON, 2);
for (let i = 0; i < stars.length; i += 1) {
const st = stars[i];
const vis = smooth((st.thr - luma(skyAt(alt, st.y / HORIZON))) / 0.07);
if (vis > 0.02) {
ctx.fillStyle = 'rgba(255,252,240,' + (vis * (0.78 + 0.22 * Math.sin(t * 0.004 + st.x))).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(st.x, st.y, 0.7 + 1.4 * st.b, 0, PI2);
ctx.fill();
}
}
const sc = ramp(SUN_RAMP, alt);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, HORIZON);
ctx.clip();
glow(ctx, p.x, HORIZON, 150, sc, 0.4 * smooth((alt + 0.18) / 0.3) * (1 - smooth(alt / 0.5)));
sunDisc(ctx, p.x, p.y, SUN_PX, SUN_PX, sc, 84);
ctx.restore();
// 구름 두 띠 — 한 값(alt)으로 윗면은 하늘 색에 남고 아랫면부터 불빛이 번진다
for (let b = 0; b < 2; b += 1) {
const cy = 62 + b * 40;
const g = ctx.createLinearGradient(0, cy - 14, 0, cy + 14);
g.addColorStop(0, css(cloudTone(0.05, alt), 0.95));
g.addColorStop(0.5, css(cloudTone(0.5, alt), 0.95));
g.addColorStop(1, css(cloudTone(1, alt), 0.95));
ctx.fillStyle = g;
const rnd = mulberry32(31 + b);
for (let k = 0; k < 7; k += 1) {
const cx = ((rnd() * W + t * (0.006 + 0.004 * b)) % (W + 120)) - 60;
ctx.beginPath();
ctx.ellipse(cx, cy + (rnd() - 0.5) * 8, 30 + rnd() * 22, 8 + rnd() * 6, 0, 0, PI2);
ctx.fill();
}
}
seaBase(ctx, alt, t);
glitterRoad(ctx, alt, p.x, t, sc);
// 해가 지나는 호와 손잡이
for (let a = 0; a <= 1.001; a += 0.025) {
const q = arcPoint(a);
if (q.y < H - 4) {
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillRect(q.x - 1, q.y - 1, 2, 2);
}
}
ctx.beginPath();
ctx.arc(p.x, p.y, SUN_PX + 6, 0, PI2);
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = '600 11px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(W - 66, 8, 58, 17);
ctx.fillStyle = 'rgba(40,36,64,0.85)';
ctx.fillText(clockText(s), W - 14, 20);
return alt;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
const ctx: Ctx = ctx0;
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
const presets = [0.2, 0.5, 0.8, 0.97];
let t = 0;
let last: number | null = null;
let paused = false;
let manual = false;
let target = reduce ? REST_S : 1 - DAY_CLAMP;
let shownS = target;
let preset = 2;
let raf = 0;
let pressed = false;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(el: HTMLElement, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
cleaners.push(function (): void { el.removeEventListener(type, fn); });
}
function show(): void {
const a = drawScene(ctx, shownS, t);
const s = labelOf(a);
if (s !== lastLabel) { lastLabel = s; statusEl.textContent = s; }
}
function setButton(): void { btnEl.textContent = reduce ? '다음 장면' : (paused || manual ? '재생' : '멈춤'); }
// 사용자가 끌거나 키를 누르면 자동 시연을 멈추고 그 값으로 간다 (움직임 줄이기에서도 입력은 즉시 반영)
function takeOver(s: number): void {
target = clamp(s, DAY_CLAMP, 1 - DAY_CLAMP);
manual = true;
if (reduce) { shownS = target; } else { paused = false; rootEl.classList.remove('is-demo'); setButton(); }
show();
}
function onButton(): void {
if (reduce) {
preset = (preset + 1) % presets.length;
target = presets[preset];
shownS = target;
show();
return;
}
if (manual) {
manual = false;
t = 0;
rootEl.classList.add('is-demo');
paused = false;
} else {
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
}
setButton();
}
function toS(e: PointerEvent): number {
const r = canvas.getBoundingClientRect();
return sFromPointer((e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height);
}
function tick(ts: number): void {
if (last === null) { last = ts; }
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
if (!manual) {
t += dt;
target = 0.5 + (0.5 - DAY_CLAMP) * Math.cos(PI2 * t / AUTO_MS);
}
shownS += (target - shownS) * (1 - Math.pow(1 - DRAG_SMOOTH, dt / 16.7));
show();
}
raf = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void { pressed = true; takeOver(toS(e as PointerEvent)); });
listen(canvas, 'pointermove', function (e: Event): void { if (pressed) { takeOver(toS(e as PointerEvent)); } });
listen(canvas, 'pointerup', function (): void { pressed = false; });
listen(canvas, 'pointercancel', function (): void { pressed = false; });
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); return; }
let d = 0;
if (k === 'ArrowRight' || k === 'ArrowUp') { d = SCRUB_STEP; }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { d = -SCRUB_STEP; }
else { return; }
e.preventDefault();
takeOver(target + d);
});
setButton();
show();
if (!reduce) { raf = requestAnimationFrame(tick); }
return function (): void {
cancelAnimationFrame(raf);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
export default function SsDragSunTimeScrub() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="해를 끌어 하루를 되감는 노을 시간 스크러버" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">해가 졌다 — 땅거미</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
</div>
</main>
);
}08 · 해를 끌어 하루를 되감는 노을 시간 스크러버 — 측정 누적면적 75.469% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 호 위의 위치 s(0 = 왼쪽 끝, 1 = 오른쪽 끝) -> 해의 화면 좌표와 높이
function arcPoint(s) {
const x = ARC_CX - ARC_RADIUS * Math.cos(Math.PI * s);
const y = ARC_CY - ARC_RADIUS * Math.sin(Math.PI * s);
return { x: x, y: y, alt: (HORIZON - y) / SUN_RISE };
}
// 포인터 위치 -> 호 위 s. 호 아래로 내려가면 가까운 끝으로 붙는다
function sFromPointer(px, py) {
const ang = Math.atan2(ARC_CY - py, px - ARC_CX);
if (ang < 0) {
return px < ARC_CX ? DAY_CLAMP : 1 - DAY_CLAMP;
}
return clamp(1 - ang / Math.PI, DAY_CLAMP, 1 - DAY_CLAMP);
}
function tick(ts) {
if (last === null) {
last = ts;
}
const dt = Math.min(ts - last, 160);
last = ts;
if (!paused) {
if (!manual) {
t += dt;
target = 0.5 + (0.5 - DAY_CLAMP) * Math.cos(PI2 * t / AUTO_MS);
}
shownS += (target - shownS) * (1 - Math.pow(1 - DRAG_SMOOTH, dt / 16.7));
show();
}
raf = requestAnimationFrame(tick);
}
09색 위치를 등록 속성으로 움직이는 순수 CSS 노을 그라디언트
색 세 개·멈춤 위치 두 값·해 위치·해 색·바다 두 색·윤슬 투명도를 @property 등록 속성 10개로 선언하고, 키프레임 하나가 0·30·55·80·100% 다섯 지점의 값 사이를 이어 주므로 스크립트 없이 하늘이 저문다. 멈춤은 체크박스와 <label> 이 맡아 :has 로 애니메이션을 일시정지시키니 가벼운 노을 배경이 필요한 자리에 얹기 좋다. 측정에서는 누적면적 73.1%, 움직인 프레임 23/23 이 나왔다.

<main class="stage">
<div class="sc" id="scRoot">
<input class="sc__toggle" type="checkbox" id="scPause" aria-label="멈춤">
<div class="sc__sky"></div>
<div class="sc__sea"><div class="sc__glint"></div></div>
<p class="sc__status" role="status" aria-live="polite">CSS 만으로 저무는 하늘</p>
<label class="sc__btn" for="scPause"><span class="sc__off">멈춤</span><span class="sc__on">재생</span></label>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
$duration: 3.2s;
$easing: ease-in-out;
$horizon: 62%;
$sun-x: 65%;
@property --sky-top {
syntax: "<color>";
inherits: true;
initial-value: #807eb6;
}
@property --sky-mid {
syntax: "<color>";
inherits: true;
initial-value: #b38fa8;
}
@property --sky-low {
syntax: "<color>";
inherits: true;
initial-value: #e6b7ab;
}
@property --stop-a {
syntax: "<percentage>";
inherits: true;
initial-value: 44%;
}
@property --stop-b {
syntax: "<percentage>";
inherits: true;
initial-value: 63%;
}
@property --sun-y {
syntax: "<percentage>";
inherits: true;
initial-value: 56.0%;
}
@property --sun {
syntax: "<color>";
inherits: true;
initial-value: #e4bca4;
}
@property --sea-top {
syntax: "<color>";
inherits: true;
initial-value: #bc8d8f;
}
@property --sea-deep {
syntax: "<color>";
inherits: true;
initial-value: #3e5c8a;
}
@property --glint-a {
syntax: "<number>";
inherits: true;
initial-value: 0.98;
}
@keyframes sunset {
0% {
--sky-top: #a1c1e3;
--sky-mid: #bacee8;
--sky-low: #f0e2de;
--stop-a: 30%;
--stop-b: 66%;
--sun-y: 32.0%;
--sun: #ffe9bf;
--sea-top: #b9c0cc;
--sea-deep: #5480aa;
--glint-a: 1;
}
30% {
--sky-top: #96a0d6;
--sky-mid: #c0b3c3;
--sky-low: #ffd0a6;
--stop-a: 38%;
--stop-b: 64%;
--sun-y: 47.0%;
--sun: #ffd4a2;
--sea-top: #c3b4a7;
--sea-deep: #5480aa;
--glint-a: 1;
}
55% {
--sky-top: #807eb6;
--sky-mid: #b38fa8;
--sky-low: #e6b7ab;
--stop-a: 44%;
--stop-b: 63%;
--sun-y: 56.0%;
--sun: #e4bca4;
--sea-top: #bc8d8f;
--sea-deep: #3e5c8a;
--glint-a: 0.98;
}
80% {
--sky-top: #5c5c9c;
--sky-mid: #966d96;
--sky-low: #df959a;
--stop-a: 48%;
--stop-b: 62%;
--sun-y: 62.0%;
--sun: #e1a99c;
--sea-top: #ac6f85;
--sea-deep: #304474;
--glint-a: 0.3;
}
100% {
--sky-top: #393c74;
--sky-mid: #5f5283;
--sky-low: #99729a;
--stop-a: 50%;
--stop-b: 62%;
--sun-y: 67.0%;
--sun: #e09a97;
--sea-top: #705b86;
--sea-deep: #243062;
--glint-a: 0;
}
}
@keyframes shimmer {
50% { transform: translate(.6em, 7px); }
to { transform: translate(0, 14px); }
}
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
font-size: calc(min(92vw, 160vh - 12.8vw) / 30);
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
animation: sunset $duration $easing infinite alternate;
}
.sc__sky {
position: absolute;
inset: 0;
background:
radial-gradient(circle at $sun-x var(--sun-y), color-mix(in srgb, var(--sun) 55%, transparent) 0, transparent 7.5em),
radial-gradient(circle at $sun-x var(--sun-y), var(--sun) 0.94em, transparent 1.04em),
linear-gradient(180deg, var(--sky-top) 0%, var(--sky-mid) var(--stop-a), var(--sky-low) var(--stop-b));
}
.sc__sea {
position: absolute;
left: 0;
right: 0;
top: $horizon;
bottom: 0;
overflow: hidden;
background:
linear-gradient(180deg, color-mix(in srgb, var(--sky-low) 45%, transparent) 0, transparent 40%),
linear-gradient(180deg, var(--sea-top), var(--sea-deep));
}
.sc__glint {
position: absolute;
inset: -1em 0;
background:
repeating-linear-gradient(180deg, color-mix(in srgb, var(--sun) 55%, white) 0 2px, transparent 2px 7px),
repeating-linear-gradient(180deg, white 0 1px, transparent 1px 14px);
clip-path: polygon(61% 0, 69% 0, 82% 100%, 48% 100%);
mask-image:
linear-gradient(90deg, transparent 40%, #000 56%, #000 74%, transparent 90%),
repeating-linear-gradient(90deg, #000 0 6%, transparent 6% 7.5%, #000 7.5% 15%, transparent 15% 17%, #000 17% 24%, transparent 24% 25%);
mask-composite: intersect;
opacity: var(--glint-a);
animation: shimmer 1.6s linear infinite;
}
.sc__toggle {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__on { display: none; }
.sc__toggle:checked ~ .sc__btn .sc__on { display: inline; }
.sc__toggle:checked ~ .sc__btn .sc__off { display: none; }
.sc__toggle:focus-visible ~ .sc__btn { outline: 2px solid #ffffff; outline-offset: 2px; }
.sc:has(.sc__toggle:checked),
.sc:has(.sc__toggle:checked) .sc__glint {
animation-play-state: paused;
}
@media (max-width: 340px) {
.sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; max-width: 58%; }
}
@media (prefers-reduced-motion: reduce) {
.sc, .sc__glint { animation: none; }
}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);
}
@property --sky-top {
syntax: "<color>";
inherits: true;
initial-value: #807eb6;
}
@property --sky-mid {
syntax: "<color>";
inherits: true;
initial-value: #b38fa8;
}
@property --sky-low {
syntax: "<color>";
inherits: true;
initial-value: #e6b7ab;
}
@property --stop-a {
syntax: "<percentage>";
inherits: true;
initial-value: 44%;
}
@property --stop-b {
syntax: "<percentage>";
inherits: true;
initial-value: 63%;
}
@property --sun-y {
syntax: "<percentage>";
inherits: true;
initial-value: 56%;
}
@property --sun {
syntax: "<color>";
inherits: true;
initial-value: #e4bca4;
}
@property --sea-top {
syntax: "<color>";
inherits: true;
initial-value: #bc8d8f;
}
@property --sea-deep {
syntax: "<color>";
inherits: true;
initial-value: #3e5c8a;
}
@property --glint-a {
syntax: "<number>";
inherits: true;
initial-value: 0.98;
}
@keyframes sunset {
0% {
--sky-top: #a1c1e3;
--sky-mid: #bacee8;
--sky-low: #f0e2de;
--stop-a: 30%;
--stop-b: 66%;
--sun-y: 32.0%;
--sun: #ffe9bf;
--sea-top: #b9c0cc;
--sea-deep: #5480aa;
--glint-a: 1;
}
30% {
--sky-top: #96a0d6;
--sky-mid: #c0b3c3;
--sky-low: #ffd0a6;
--stop-a: 38%;
--stop-b: 64%;
--sun-y: 47.0%;
--sun: #ffd4a2;
--sea-top: #c3b4a7;
--sea-deep: #5480aa;
--glint-a: 1;
}
55% {
--sky-top: #807eb6;
--sky-mid: #b38fa8;
--sky-low: #e6b7ab;
--stop-a: 44%;
--stop-b: 63%;
--sun-y: 56.0%;
--sun: #e4bca4;
--sea-top: #bc8d8f;
--sea-deep: #3e5c8a;
--glint-a: 0.98;
}
80% {
--sky-top: #5c5c9c;
--sky-mid: #966d96;
--sky-low: #df959a;
--stop-a: 48%;
--stop-b: 62%;
--sun-y: 62.0%;
--sun: #e1a99c;
--sea-top: #ac6f85;
--sea-deep: #304474;
--glint-a: 0.3;
}
100% {
--sky-top: #393c74;
--sky-mid: #5f5283;
--sky-low: #99729a;
--stop-a: 50%;
--stop-b: 62%;
--sun-y: 67.0%;
--sun: #e09a97;
--sea-top: #705b86;
--sea-deep: #243062;
--glint-a: 0;
}
}
@keyframes shimmer {
50% {
transform: translate(0.6em, 7px);
}
to {
transform: translate(0, 14px);
}
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
font-size: calc(min(92vw, 160vh - 12.8vw) / 30);
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
animation: sunset 3.2s ease-in-out infinite alternate;
}
.sc__sky {
position: absolute;
inset: 0;
background: radial-gradient(circle at 65% var(--sun-y), color-mix(in srgb, var(--sun) 55%, transparent) 0, transparent 7.5em), radial-gradient(circle at 65% var(--sun-y), var(--sun) 0.94em, transparent 1.04em), linear-gradient(180deg, var(--sky-top) 0%, var(--sky-mid) var(--stop-a), var(--sky-low) var(--stop-b));
}
.sc__sea {
position: absolute;
left: 0;
right: 0;
top: 62%;
bottom: 0;
overflow: hidden;
background: linear-gradient(180deg, color-mix(in srgb, var(--sky-low) 45%, transparent) 0, transparent 40%), linear-gradient(180deg, var(--sea-top), var(--sea-deep));
}
.sc__glint {
position: absolute;
inset: -1em 0;
background: repeating-linear-gradient(180deg, color-mix(in srgb, var(--sun) 55%, white) 0 2px, transparent 2px 7px), repeating-linear-gradient(180deg, white 0 1px, transparent 1px 14px);
clip-path: polygon(61% 0, 69% 0, 82% 100%, 48% 100%);
mask-image: linear-gradient(90deg, transparent 40%, #000 56%, #000 74%, transparent 90%), repeating-linear-gradient(90deg, #000 0 6%, transparent 6% 7.5%, #000 7.5% 15%, transparent 15% 17%, #000 17% 24%, transparent 24% 25%);
mask-composite: intersect;
opacity: var(--glint-a);
animation: shimmer 1.6s linear infinite;
}
.sc__toggle {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__on {
display: none;
}
.sc__toggle:checked ~ .sc__btn .sc__on {
display: inline;
}
.sc__toggle:checked ~ .sc__btn .sc__off {
display: none;
}
.sc__toggle:focus-visible ~ .sc__btn {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc:has(.sc__toggle:checked),
.sc:has(.sc__toggle:checked) .sc__glint {
animation-play-state: paused;
}
@media (max-width: 340px) {
.sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
max-width: 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.sc, .sc__glint {
animation: none;
}
}import styles from "./SsPropertyGradientStopsLoop.module.scss";
export default function SsPropertyGradientStopsLoop() {
return (
<main className={styles.stage}>
<div className={styles.sc}>
<input className={styles.sc__toggle} type="checkbox" id="scPause" aria-label="멈춤" />
<div className={styles.sc__sky} />
<div className={styles.sc__sea}><div className={styles.sc__glint} /></div>
<p className={styles.sc__status} role="status" aria-live="polite">CSS 만으로 저무는 하늘</p>
<label className={styles.sc__btn} htmlFor="scPause"><span className={styles.sc__off}>멈춤</span><span className={styles.sc__on}>재생</span></label>
</div>
</main>
);
}09 · 색 위치를 등록 속성으로 움직이는 순수 CSS 노을 그라디언트 — 측정 누적면적 73.117% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@property --sky-top {
syntax: "<color>";
inherits: true;
initial-value: #807eb6;
}
@property --sky-mid {
syntax: "<color>";
inherits: true;
initial-value: #b38fa8;
}
@property --sky-low {
syntax: "<color>";
inherits: true;
initial-value: #e6b7ab;
}
.sc__glint {
position: absolute;
inset: -1em 0;
background:
repeating-linear-gradient(180deg, color-mix(in srgb, var(--sun) 55%, white) 0 2px, transparent 2px 7px),
repeating-linear-gradient(180deg, white 0 1px, transparent 1px 14px);
clip-path: polygon(61% 0, 69% 0, 82% 100%, 48% 100%);
mask-image:
linear-gradient(90deg, transparent 40%, #000 56%, #000 74%, transparent 90%),
repeating-linear-gradient(90deg, #000 0 6%, transparent 6% 7.5%, #000 7.5% 15%, transparent 15% 17%, #000 17% 24%, transparent 24% 25%);
mask-composite: intersect;
opacity: var(--glint-a);
animation: shimmer 1.6s linear infinite;
}
값이 장면을 바꾸는 곳 — 해 높이·안개 세기·반사 폭
시작 높이를 낮추면 첫 화면이 노을 쪽에 놓인다
01 의 SUN_ALT 는 자동 시연이 출발하는 해 높이다. 0.5 에서 0.2 로 바꾸면 해 y 가 111 에서 156 으로 내려오고, 첫 화면의 상태 문구가 해가 기운다 — 금빛 오후 에서 해가 낮다 — 노을 로 바뀐다.
안개 비율은 먼 능선부터 하늘로 보낸다
06 의 HAZE_STRENGTH 는 안개 비율 식 HAZE_STRENGTH × (1 − k)^1.2 의 앞 계수다. 0.8 에서 0.95 로 올린 값을 식에 넣어 계산하면 가장 먼 겹이 0.8 에서 0.95 로, 둘째 겹이 0.57 에서 0.67 로 하늘색에 가까워지고 가장 가까운 겹은 0 그대로다.
반사 폭은 원근을 따라 넓어진다
04 의 GLITTER_WIDTH 22 는 윤슬 폭의 반 22 × (0.35 + 1.1 × d) 에 들어가 d 0·0.5·1 에서 7.7·19.8·31.9px 를 만든다. 34 로 넓히면 11.9·30.6·49.3px 가 되고, 줄마다 위치를 어긋내는 ROW_JITTER 를 0 으로 두면 끊김 없는 한 줄 기둥이 된다.
마지막 전체 렌더에서 잰 값을 항목별로 모았다. 누적면적은 73.1~75.5% 에 들어오고 움직인 프레임은 아홉 항목 모두 23/23 이다.
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 01 하늘 색 띠 | 74.2% | 31.3 | 23/23 |
| 02 산란 줄 | 73.9% | 37.4 | 23/23 |
| 03 납작한 해 | 75.4% | 32.7 | 23/23 |
| 04 윤슬 길 | 74.9% | 32.3 | 23/23 |
| 05 구름 아랫불빛 | 75.3% | 22.9 | 23/23 |
| 06 능선 안개 | 75.1% | 39.3 | 23/23 |
| 07 드러나는 별 | 74.2% | 36.6 | 23/23 |
| 08 시간 스크러버 | 75.5% | 29.5 | 23/23 |
| 09 순수 CSS 노을 | 73.1% | 59.7 | 23/23 |
어디서 깨지는가 — 좁은 화면·해가 지평선 아래일 때·색 대비가 낮을 때
해가 지평선 아래로 내려가면 하나씩 꺼진다
04 의 윤슬은 alt −0.08 에서 가시도가 0 이 되어 완전히 꺼진다. 03 의 원반은 alt 0 에서 지평선에 반쯤 잘려 위쪽 반만 보이고 윗끝이 지평선 밑으로 가는 alt −0.088 보다 낮으면 사라진다. 바다에 비친 원반 중심은 2 × 186 − 해 y 라서 alt −0.1 이면 중심 y 171 에 세로 반지름 13.2 로 아랫끝이 184.2 에 그쳐 바다 줄(y 187 이상)에 걸리지 않는다. 색 띠는 −0.15 아래에서 끝 색에 고정되므로 07 의 to −0.16 도 −0.15 와 같은 색을 쓴다.
색 변화가 지평선 근처에 몰린다
01 의 줄 중간(v 0.5)에서 지평선 색 비중은 0.5 의 1.7 제곱인 0.31 뿐이다. SKY_BLEND 를 1 로 낮추면 비중이 0.5 가 되어 직선 그라데이션에 가까워진다. 02 는 그 길이가 시선 높이 0.5 이상에서 0.9~1.7 로 좁다가 아래 10% 에서 5~10 으로 커져 같은 방식으로 지평선 쪽에 색이 쏠린다.
02 는 가로 방향을 모른다
02 의 색은 줄, 곧 세로 위치만으로 정해져서 해 쪽이 더 밝은 하늘은 만들지 못한다. RED_KEEP 를 1 로 올리면 붉은 채널 손실이 0 이 되어 낮은 해에서도 붉은 빛이 깎이지 않는다. 해 둘레의 밝기는 같은 장면의 glow 호출이 따로 얹는다.
03 의 납작함은 alt 0.32 위에서 보이지 않는다
REDDEN_AT 0.32 보다 높은 해는 low 가 0 이라 완전한 원이다. 납작함과 붉은 기운을 보여 주고 싶으면 REDDEN_AT 를 키우거나 시작 높이를 낮춘다.
05 맨 위 층은 끝까지 불빛을 받지 못한다
LIGHT_REACH 가 0.9 라서 맨 위 층은 alt 0 까지 한 번도 불빛을 받지 않는다. 1 이상으로 올리면 맨 위까지 번지고, 160×64 저해상도를 3배로 키우는 탓에 구름 가장자리가 흐린 것은 의도한 모양이다.
07 은 흐린 별이 마지막까지 거의 안 보인다
가장 흐린 별(b 가 0 에 가까운 것)의 문턱은 0.2 인데 하늘 정점 휘도의 최저가 0.184(alt −0.16)라서 smooth 가 0.14 밖에 올라오지 못한다. 그래서 별 90개 중 48개만 보이며, VISIBLE_MIN 을 0.184 아래로 내리면 흐린 별도 나온다.
08 은 호 밖을 누르면 끝으로 붙는다
sFromPointer 는 호 아래를 누르면 가까운 끝(DAY_CLAMP 0.015, alt −0.065)으로 붙인다. 점 (240, 230)을 누르면 s 가 0.985 가 된다. 드래그하는 동안 목표 s 는 바로 바뀌지만 화면 s 는 한 프레임(60fps 기준)에 22% 씩 늦게 따라온다.
09 는 새 문법을 요구한다
@property 를 지원하지 않는 브라우저는 색을 보간하지 못해 키프레임 경계에서 색이 뚝뚝 바뀐다. color-mix·:has·mask-composite 도 필요하다. 체크박스는 투명 1px 로 숨겨져 있어 포커스 링이 라벨에 그려진다.
상태 글자와 방향키
상태 글자는 흰 알약 위의 어두운 글자라서 하늘색이 바뀌어도 하늘 위에 직접 얹히지 않는다. 01~07 의 방향키는 해 높이를 옮기는 데 쓰이는 만큼 캔버스에 포커스가 있는 동안 페이지 스크롤을 막으므로, 긴 페이지에 붙일 때는 키보드 스크롤이 멈추는지 먼저 본다.
값을 직접 바꿔 보며 경계를 확인하는 법
위의 경계는 01 의 SKY_BLEND, 03 의 REDDEN_AT, 05 의 LIGHT_REACH, 07 의 VISIBLE_MIN 을 하나씩 바꾸며 직접 볼 수 있다. 압축 파일은 비밀번호 nx9tfgpu 을 입력하면 열리고, 안에는 아홉 장면의 완결본이 들어 있다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 해 높이 바꾸기 | 01 SUN_ALT 0.5 → 0.2 | 시작 높이를 0.5 에서 0.2 로 낮추면 첫 화면부터 하늘이 노을 쪽에 가 있고, 거기서 지평선 아래까지 내려간다. |
| 안개 세기 바꾸기 | 06 HAZE_STRENGTH 0.8 → 0.95 | 0.8 에서 0.95 로 올리면 가장 먼 능선이 하늘 색에 거의 녹아 윤곽만 남는다. |
| 반사 폭 바꾸기 | 04 GLITTER_WIDTH 22 → 34 | 22 에서 34 로 넓히면 해 아래 빛의 길이 굵어지고 수평선 쪽에서도 끊김이 눈에 띄게 줄어든다. |
세 변형 모두 소스의 const NAME = 값; 한 줄이고, 앞 절의 7.7~31.9px 와 11.9~49.3px 같은 값은 소스 식에 넣어 계산한 것이다.
prefers-reduced-motion 이 켜져 있으면 01~07 은 requestAnimationFrame 루프를 시작하지 않고 from 높이로 장면을 한 장만 그린다. 버튼은 다음 장면 이 되어 누를 때마다 from 에서 to 를 3등분한 네 높이를 돌려 가며 다시 그리고, 포인터와 키 입력은 takeOver 를 거쳐 그 자리에서 한 장씩 바로 반영된다.
08 은 reduce 에서도 호 드래그·←→ 키·버튼이 동작하되 시작 s 가 0.8 이고 화면 s 가 목표를 따라가지 않고 입력 즉시 같은 값으로 바뀐다. 09 는 두 애니메이션이 animation: none 이라 등록 속성 초깃값인 금빛 장면 한 장으로 멈춘다.
01~08 의 캔버스는 tabindex 0 이라 포커스를 받고, 상태 글자는 문구가 바뀔 때만 aria-live="polite" 영역을 고친다. 09 는 체크박스와 <label> 로 Space 키 멈춤이 되고 aria-live 문구는 고정이다.
css sunset animation 은 배경색만 갈아 끼우면 충분한가?
장면이 정지한 한 장이거나 짧은 루프라면 충분하고, 그 자리를 09 가 맡는다. 해 높이 하나에 구름·바다·별이 따라 움직여야 하는 순간부터는 색을 갈아 끼우는 키프레임으로는 층마다 다른 시작 시각을 맞출 수 없어 계산이 필요해진다.
자리별로 맞는 장면도 갈린다. 01 은 노을 히어로, 02 는 하늘 배경 생성, 04 는 바다 풍경 배경, 06 은 풍경 일러스트 히어로, 07 은 해질녘에서 밤으로 넘어가는 전환 화면, 08 은 시간대 미리보기 컨트롤, 09 는 가벼운 배경에 맞다.
비용은 장면마다 다르다. 06 은 한 프레임에 능선 조각을 1,200번(240열 × 5겹) 그리고 05 는 구름 픽셀 10,240개(160×64)의 밀도를 구하므로, 배경으로 깔 때는 이 두 장면의 프레임 비용을 먼저 본다.
바다 쪽 계산을 더 보고 싶으면 캔버스 바다 애니메이션 9종 — 겹친 정현파로 파도장을 계산한다가, 해 위치에서 그림자를 구하는 방식은 css 그림자 효과 9가지 — 광원 위치에서 그림자를 계산하는 장면이 이어 준다.
css sunset gradient 를 만들기 전에 자주 묻는 질문
css sunset animation 의 속도는 어느 값을 바꾸나?
01~07 은 PERIOD_MS 4400 이 한 주기라 2.2초에 내려가고 2.2초에 올라오며, 08 은 AUTO_MS 7600 이다. 09 는 scss 맨 위 $duration 3.2s 하나가 속도를 정한다.
노을 색을 내 페이지에 맞추려면 어디를 고치나?
01~08 은 ZENITH_RAMP·HORIZON_RAMP·SUN_RAMP 의 [해 높이, [r, g, b]] 쌍을 바꾸고, 색 띠 밖(−0.15 아래, 1 위)은 끝 색에 고정된다. 09 는 키프레임 다섯 곳의 hex 값을 바꾼다.
라이브러리나 WebGL 이 있어야 돌아가나?
둘 다 필요 없다. 아홉 파일에는 외부 주소와 WebGL 호출이 한 번도 없고 캔버스 2d 와 CSS 만으로 그리므로, 파일 하나만 옮겨도 그 장면이 돈다.









