CSS Sunset Gradient: 9 Demos With a Living Sky
A css sunset gradient from two blended colors keeps the sky unchanged as the sun sinks, gives the sea no road of light and leaves cloud undersides dark —
One sun-height number redraws every scene on this page, and the nine below are the ones worth copying into a hero, a loading screen or a time-of-day control.
See All Nine Sunset Scenes Running on This Page
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 A Sunset Backdrop Whose Sky Color Follows the Sun Height
- 02 A Scattering Sky Painted Row by Row by Light Path Length
- 03 A Sun That Flattens and Reddens as It Meets the Horizon
- 04 A Sea Glitter Path Running Down From the Sun
- 05 Cloud Bands Whose Undersides Redden First as the Sun Drops
- 06 Layered Ridges That Melt Into the Sky Color With Distance
- 07 Stars That Appear One by One as the Sky Darkens
- 08 A Sunset Time Scrubber You Drag the Sun Through
- 09 A Pure CSS Sunset Gradient Moving Its Stops Through Registered Properties
CSS Sunset Gradient: 9 Demos Where Sun Height, Light Path and Reflection Are Computed
Scenes 01 to 08 share one body: a 480 by 300 canvas, a status line, one button and four helpers, ramp (reads a color band), skyAt (paints one sky point), seaBase (lays the water) and glow (soft light). The only part that differs from scene to scene is scene.draw, while 09 is plain CSS with no <script> tag.
The single input is the sun height alt. At 0 the sun rests on the horizon, at 1 it floats high and negative values sit below the line; the screen y is 186 − alt × 150, so alt 0.5 lands at 111, 0.2 at 156 and −0.12 at 204. Scenes 01 to 07 run on their own with PERIOD_MS 4400 as one cycle, sinking from from to to in 2.2 seconds and climbing back in another 2.2, while 08 sweeps on AUTO_MS 7600.
Two blended colors cannot make a sunset because the colors have no idea where the sun is. Here each sky row is a function of the sun height and the row position, and the sea, clouds and stars read the same alt, so what you gain over a sunset css snippet of two gradient stops is one input that picks a different color band for every layer.
getImageData appears in none of the nine files, and the only place pixels are written by hand is 05, through createImageData and putImageData. Scenes 01 to 08 hold requestAnimationFrame, matchMedia and pointermove, while 09 relies on a CSS media query alone. Star and cloud positions come from mulberry32 with a fixed seed (448 in 07, 808 in 08), so reloading seats everything in the same spots.
Code for Each Sunset Technique in a CSS Sunset Beach Scene
Each snippet below is a run of consecutive lines cut from one file, one or two per scene. The sky colors for a css sunset beach backdrop come from three arrays in 01, ZENITH_RAMP, HORIZON_RAMP and SUN_RAMP, so start there if you only want to recolor the sky. The full files sit in the zip further down.
01A Sunset Backdrop Whose Sky Color Follows the Sun Height
skyAt computes v = row ÷ 186 for every row and blends a zenith color and a horizon color, both picked from color bands by the sun height, with v raised to SKY_BLEND 1.7. As the sun descends both picks slide along their bands together, so the whole sky warms at once and the scene suits a sunset hero background. The render logged 74.2% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A Sunset Backdrop Whose Sky Color Follows the Sun Height"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun is leaning — a golden afternoon</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused ? 'Play' : 'Pause');
}
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 · A Sunset Backdrop Whose Sky Color Follows the Sun Height — Measured area 74.222% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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];
}
// Color of one sky point; v runs from 0 (zenith) to 1 (horizon)
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);
}
};
02A Scattering Sky Painted Row by Row by Light Path Length
rowColor finds the scatter amount for each row from the line-of-sight length 1 ÷ (el + 0.1), then trims blue first by the length the sunlight traveled, so a low sun leaves red in the row color. That path length climbs from 0.91 at the zenith to 10 at the horizon, which packs the color change into eye heights of 0.1 and below and fits anyone generating a sunset sky backdrop from one formula. This scene reached 73.9% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A Scattering Sky Painted Row by Row by Light Path Length"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun is leaning — a golden afternoon</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused ? 'Play' : 'Pause');
}
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 · A Scattering Sky Painted Row by Row by Light Path Length — Measured area 73.88% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Color of one row; el is the eye height of that row (1 = zenith, 0 = horizon). The lower it is, the longer the path through air
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;
}
03A Sun That Flattens and Reddens as It Meets the Horizon
The vertical radius of the disc shrinks by FLATTEN_MAX 0.34, the horizontal radius widens by a plain 0.28 factor in the code, and the color slides from pale yellow to coral, all three following the single value low. At alt 0, low is 1, which gives a flat sun with radii 13.2 vertical and 25.6 horizontal, and the disc is then mirrored about the horizon and rippled onto the sea. Its numbers: 75.4% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A Sun That Flattens and Reddens as It Meets the Horizon"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun is leaning — a golden afternoon</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused ? 'Play' : 'Pause');
}
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 · A Sun That Flattens and Reddens as It Meets the Horizon — Measured area 75.381% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Rises from 0 to 1 as the sun sinks; flatness, redness and halo all follow this one value
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();
// The sun on the sea: the disc flipped about the horizon, cut per row and shifted by the ripple
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);
}
}
04A Sea Glitter Path Running Down From the Sun
glitterRoad cuts the sea into 57 rows of 2 px, finds each row's wave-shifted center, and turns nine pieces around it on and off with vnoise, which leaves a broken road of light under the sun. The half-width grows from 7.7 px at the horizon toward about 31.7 px on the last row, so perspective comes for free and the scene drops straight into a seascape backdrop. It covered 74.9% cumulative area with 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A Sea Glitter Path Running Down From the Sun"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun is low — sunset glow</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused ? 'Play' : 'Pause');
}
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 · A Sea Glitter Path Running Down From the Sun — Measured area 74.935% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Sea glitter road: find each water row's wave-shifted position and light only the pieces within the width under the sun
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);
}
}
}
}
05Cloud Bands Whose Undersides Redden First as the Sun Drops
One 160×64 ImageData gets a fbm density per pixel, and underlit rates each of four horizontal layers separately so the bottom ones turn red first. At alt 0.3 the lower two layers glow and at alt 0 the lower three do, while the top layer keeps its own color because LIGHT_REACH 0.9 stops short of 1. That scene measured 75.3% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="Cloud Bands Whose Undersides Redden First as the Sun Drops"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun is leaning — a golden afternoon</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused ? 'Play' : 'Pause');
}
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 · Cloud Bands Whose Undersides Redden First as the Sun Drops — Measured area 75.346% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// How much sunlight one cloud layer (0 = top, 1 = bottom) receives; the lower the sun, the higher the light reaches
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);
}
06Layered Ridges That Melt Into the Sky Color With Distance
ridgeTop sets the height of five ridges with fbm, and each farther ridge mixes in more of the current sky color, with haze ratios 0.80, 0.57, 0.35, 0.15 and 0. The share mixed toward dark navy climbs from 0.25 to 0.77 toward the near ridges, so the front stays dark while the back dissolves, which gives a landscape illustration hero its depth. The render came to 75.1% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="Layered Ridges That Melt Into the Sky Color With Distance"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun is leaning — a golden afternoon</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused ? 'Play' : 'Pause');
}
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 · Layered Ridges That Melt Into the Sky Color With Distance — Measured area 75.139% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Height of one ridge; baseline, amplitude, frequency and drift speed differ per layer index i (0 = farthest)
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);
// Nearer ridges go darker, farther ones go toward the current sky color; both come from one ridge index
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);
}
}
07Stars That Appear One by One as the Sky Darkens
Each of the 90 stars has a brightness b and its own sky-brightness threshold thr between 0.2 and 0.62, and when the sky behind it drops below that threshold smooth fades it in. Bright stars come out first and faint ones trail, so the visible count grows from 4 at alt 0.14 to 13 at 0 and 48 at −0.16, which suits a dusk-to-night transition. Its render logged 74.2% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="Stars That Appear One by One as the Sky Darkens"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun is low — sunset glow</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused ? 'Play' : 'Pause');
}
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 · Stars That Appear One by One as the Sky Darkens — Measured area 74.174% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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];
// Reference for one star: it starts to show once the sky brightness there falls below its threshold
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);
}
}
}
}
08A Sunset Time Scrubber You Drag the Sun Through
arcPoint turns a position s (0 to 1) on the arc into a sun point and an alt, and that alt redraws the sky, stars, clouds, sea and glitter, with the sun x feeding only the glow and the glitter center. The shown s chases the target s by DRAG_SMOOTH 0.22 per frame, and the clock text is 6:00 plus 12 hours × s, so s 0.5 reads h 12:00, which makes the scene a time-of-day preview control. The figures were 75.5% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="A Sunset Time Scrubber You Drag the Sun Through"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">The sun has set — twilight</p>
<button class="sc__btn" id="scBtn" type="button">Pause</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 = 'The sun is high — a daytime sky';
const TXT_GOLD = 'The sun is leaning — a golden afternoon';
const TXT_GLOW = 'The sun is low — sunset glow';
const TXT_TOUCH = 'The sun touches the horizon — a red band';
const TXT_DUSK = 'The sun has set — twilight';
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 ? 'Next scene' : (paused || manual ? 'Play' : 'Pause'); }
// 사용자가 끌거나 키를 누르면 자동 시연을 멈추고 그 값으로 간다 (움직임 줄이기에서도 입력은 즉시 반영)
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 · A Sunset Time Scrubber You Drag the Sun Through — Measured area 75.469% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Position s on the arc (0 = left end, 1 = right end) -> screen point and height of the sun
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 };
}
// Pointer position -> s on the arc; below the arc it snaps to the nearer end
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);
}
09A Pure CSS Sunset Gradient Moving Its Stops Through Registered Properties
Ten @property registrations hold the three sky colors, two stop positions, the sun position and color, two sea colors and the glint opacity, and a single keyframe set at 0, 30, 55, 80 and 100% glides between their values with no script. A checkbox with a <label> handles pausing through :has, so the scene fits a place that needs a light sunset background. It came out at 73.1% cumulative area and 23/23 moved frames.

<main class="stage">
<div class="sc" id="scRoot">
<input class="sc__toggle" type="checkbox" id="scPause" aria-label="Pause">
<div class="sc__sky"></div>
<div class="sc__sea"><div class="sc__glint"></div></div>
<p class="sc__status" role="status" aria-live="polite">A sky that sets with CSS alone</p>
<label class="sc__btn" for="scPause"><span class="sc__off">Pause</span><span class="sc__on">Play</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 · A Pure CSS Sunset Gradient Moving Its Stops Through Registered Properties — Measured area 73.117% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
@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;
}
Where the Values Change the Scene — Sun Height, Haze Strength, Glitter Width
A lower starting height opens the page on the sunset side
SUN_ALT in 01 is the height the auto run starts from. Moving it from 0.5 to 0.2 drops the sun's y from 111 to 156, and the first status line changes from The sun is leaning — a golden afternoon to The sun is low — sunset glow.
Haze sends the far ridges to the sky first
HAZE_STRENGTH in 06 is the leading factor of the haze formula HAZE_STRENGTH × (1 − k)^1.2. At 0.95 instead of 0.8 the farthest ridge goes from 0.8 to 0.95, the second from 0.57 to 0.67 (computed from the formula, not measured), and the nearest ridge stays at 0.
Glitter width widens with perspective
GLITTER_WIDTH 22 sits inside the half-width 22 × (0.35 + 1.1 × d), which gives 7.7, 19.8 and 31.9 px at d 0, 0.5 and 1 (the real last row is d 0.98, about 31.7 px). At 34 the same three points become 11.9, 30.6 and 49.3 px, and setting ROW_JITTER to 0 turns the broken road into one unbroken column.
The table collects what the last full render measured. Cumulative area stays between 73.1% and 75.5%, and every one of the nine scenes moved in 23 of 23 frames.
| Scene | Cumulative area | Mean intensity | Moved frames |
|---|---|---|---|
| 01 Sky color band | 74.2% | 31.3 | 23/23 |
| 02 Scattering rows | 73.9% | 37.4 | 23/23 |
| 03 Flattened sun | 75.4% | 32.7 | 23/23 |
| 04 Glitter path | 74.9% | 32.3 | 23/23 |
| 05 Cloud underlight | 75.3% | 22.9 | 23/23 |
| 06 Ridge haze | 75.1% | 39.3 | 23/23 |
| 07 Emerging stars | 74.2% | 36.6 | 23/23 |
| 08 Time scrubber | 75.5% | 29.5 | 23/23 |
| 09 Pure CSS sunset | 73.1% | 59.7 | 23/23 |
Where It Breaks — Narrow Screens, a Sun Below the Horizon, Low Color Contrast
Things switch off one by one once the sun passes the horizon
The glitter in 04 hits visibility 0 at alt −0.08 and is fully off. The disc in 03 is cut in half at alt 0 with only the top half showing, and it disappears below alt −0.088. The sea mirror of the disc is centered at 2 × 186 minus the sun y, so at alt −0.1 the center is y 171, the vertical radius is 13.2 and the bottom edge stops at 184.2, never reaching the sea rows (y 187 and below). The color bands freeze at their end colors below −0.15, so 07's to of −0.16 uses the same color as −0.15.
Color change crowds the horizon
At the middle of a row in 01 (v 0.5) the horizon color weighs only 0.5 to the power 1.7, which is 0.31. Lowering SKY_BLEND to 1 makes the weight 0.5 and the result close to a straight gradient. In 02 the path length is 0.9 to 1.7 from eye height 0.5 upward and then jumps to 5 to 10 in the bottom 10%, so the colors crowd the horizon the same way.
Scene 02 knows nothing about left and right
The color in 02 is set by the row alone, so the sky never brightens toward the sun's side. Raising RED_KEEP to 1 sets the red channel loss to 0, which keeps the red light from being trimmed even at a low sun. The glow around the sun comes from the separate glow call in the same scene.
Scene 03 looks round above alt 0.32
For a sun higher than REDDEN_AT 0.32, low is 0 and the disc is a full circle. To show the flattening and the red tint at higher heights, raise REDDEN_AT or start lower.
Scene 05 leaves its top layer unlit
Because LIGHT_REACH is 0.9, the top layer gets no underlight at any alt. Setting it to 1 or more lets the glow reach the top, and the soft cloud edges come from stretching the 160×64 image three times, which is intended.
Scene 07 barely shows its faintest stars
The faintest star (b near 0) has a threshold of 0.2, and the lowest zenith brightness is 0.184 at alt −0.16, so smooth only reaches about 0.14. That is why just 48 of the 90 stars show; lowering VISIBLE_MIN below 0.184 brings the faint ones out.
Scene 08 snaps to an end when you press below the arc
sFromPointer pulls a press below the arc to the nearer end (DAY_CLAMP 0.015, alt −0.065), so the point (240, 230) gives s 0.985. While you drag, the target s changes at once but the shown s trails by 22% per frame at 60 fps.
Scene 09 needs newer CSS
A browser without @property cannot interpolate the colors, so they jump at the keyframe boundaries. color-mix, :has and mask-composite are required too. The checkbox is hidden as a transparent 1 px box, so the focus ring is drawn on the label.
The status text, narrow screens and arrow keys
The status text sits on a white pill (rgba(255, 255, 255, .85)) with dark ink, so it never depends on the sky behind it. Below 320 px the button and status text shrink to 10 px and the pill is capped at 58% of the width, while the canvas fills the stage at min(92vw, 160vh − 12.8vw). Scenes 01 to 08 block page scroll with preventDefault while the canvas has focus, so on a long page check that keyboard scrolling still works.
Changing the constants yourself
You can see each edge above by changing SKY_BLEND in 01, REDDEN_AT in 03, LIGHT_REACH in 05 and VISIBLE_MIN in 07 one at a time. The archive opens with the password nx9tfgpu and holds the finished file for all nine scenes.
Three Variants and Accessibility
| Name | Value | Feel |
|---|---|---|
| Change the sun height | 01 SUN_ALT 0.5 → 0.2 | Lowering the starting height from 0.5 to 0.2 puts the sky on the sunset side from the first frame, and it then sinks below the horizon. |
| Change the haze strength | 06 HAZE_STRENGTH 0.8 → 0.95 | Raising it from 0.8 to 0.95 melts the farthest ridge almost into the sky, leaving only its outline. |
| Change the glitter width | 04 GLITTER_WIDTH 22 → 34 | Widening it from 22 to 34 thickens the road of light under the sun and the gaps shrink visibly even near the horizon. |
Each variant is one const NAME = value; line in the source, and the 7.7 to 31.9 px and 11.9 to 49.3 px figures above are values of the source formula at d 0 to 1, not measurements.
With prefers-reduced-motion set to reduce, scenes 01 to 07 do not start a requestAnimationFrame loop and draw one still at the from height. The button becomes Next scene and cycles four heights, the from to to range split in three, and pointer and key input go through takeOver and redraw that very spot at once. Time t stays at 0, so the cloud drift in 05 and the wave stripes stay still too.
Scene 08 keeps its drag, its ←→ keys and its button in reduce, but the start s is 0.8 and the shown s jumps straight to the target with DRAG_SMOOTH ignored. Scene 09 sets both animations to animation: none and stops on the golden still given by the registered initial values.
The canvases in 01 to 08 have tabindex 0 and take focus, and the status text is rewritten only when its wording changes, inside an aria-live="polite" region. Scene 09 pauses with Space on its checkbox label and its aria-live line is fixed.
Is Swapping the Background Color Enough for a CSS Sunset Animation?
For a still image or a short loop it is enough, and 09 takes that seat. Once clouds, sea and stars have to follow one sun height, a keyframe that swaps colors cannot line up the different start times per layer, and the calculation takes over.
Where each scene fits also differs. 01 suits a sunset hero, 02 a generated sky background, 04 a seascape backdrop, 06 a landscape illustration hero, 07 a dusk-to-night transition, 08 a time-of-day preview control and 09 a lightweight background.
The cost differs as well: 06 draws 1,200 ridge slices per frame (240 columns × 5 layers) and 05 computes density for 10,240 cloud pixels (160×64), so check those two frame costs first before laying either under a whole page.
For more of the sea math see 9 Canvas Wave Animation Scenes: Gerstner Field, and for shadows computed from the sun position see CSS Shadow Effects: 9 Demos That Compute the Cast.
Frequently Asked Questions Before Building a CSS Sunset Gradient
Which value sets the speed of a css sunset animation?
Scenes 01 to 07 run on PERIOD_MS 4400, sinking in 2.2 seconds and rising in 2.2, while 08 uses AUTO_MS 7600. In 09 the single $duration 3.2s at the top of the SCSS file sets the pace.
Where do I change the sunset colors to match my page?
In 01 to 08 edit the [sun height, [r, g, b]] pairs in ZENITH_RAMP, HORIZON_RAMP and SUN_RAMP; outside the band (below −0.15 and above 1) the end color holds. In 09 change the hex values at the five keyframe stops.
Do I need a library or WebGL?
Neither. None of the nine files has an external address or a WebGL call, and the scenes draw with canvas 2d and CSS alone, so moving one file is enough for that scene to run.









