02A Scattering Sky Painted Row by Row by Light Path Length
For each canvas row it computes how much light scatters from the length of the line of sight through air, then cuts blue first by the length the sunlight traveled, so rows keep more red as the sun sinks.
Best forA generated sunset sky background
AIR_DENSITYPATH_GAINRED_KEEPROW_STEP

<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 · CSS Sunset Gradient: 9 Demos With a Living Sky
The post this demo lives in
CSS Sunset Gradient: 9 Demos With a Living SkyA css sunset gradient from two blended colors keeps the sky unchanged as the sun sinks, gives the sea no road of light a See all demos in this post


