09山の稜線・オーロラ・湖の映り込みをまとめた視差ヒーロー
中点をランダムに揺らして作った稜線三層をポインターの位置へ lerp して奥行きごとに違う量だけずらし、01 のカーテンと 04 の映り込みを一つの場面に重ねて、トップ画面全体が夜の湖畔の景色になる。
使いどころ旅行商品ランディングのトップ画面
RIDGE_LAYERSRIDGE_ROUGHNESSPARALLAX_LERP

<main class="stage">
<div class="au is-demo" id="au" tabindex="0" role="group" aria-label="湖畔の景色 — 矢印キーで視点を動かします">
<canvas class="au__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="au__hero">
<p class="au__kicker">夜の湖畔で過ごす三日間</p>
<h2 class="au__title">オーロラの下でひと休みする夜</h2>
</div>
<button class="au__pause" data-role="pause" type="button" aria-pressed="false">動きを止める</button>
<p class="au__status" data-role="status" aria-live="off">ポインターに合わせて稜線が奥行きごとに動きます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.au {
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;
}
// 05·09 는 무대 자체가 초점을 받는다(화살표 키) — 고리는 크림색으로 어두운 하늘 위에서 보이게
.au:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.au__canvas {
display: block;
width: 100%;
height: 100%;
// 손가락 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
touch-action: pan-y;
}
.au__pause {
position: absolute;
top: $sp-2;
right: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border: 1px solid rgba(255, 247, 230, .45);
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: $subject-cream;
font-family: inherit;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.au__pause:hover {
background: rgba(23, 20, 26, .92);
}
.au__pause:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
.au__pause[hidden] {
display: none;
}
.au__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 54%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.au__status { font-size: 10px; padding: $sp-1 $sp-2; }
.au__pause { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 움직임은 스크립트가 matchMedia 로 멈춘다(노이즈 시간을 세운 한 장면). CSS 쪽은 멈춤 버튼을 감추는 hidden 만 쓴다
@media (prefers-reduced-motion: reduce) {
.au__pause { transition: none; }
}
// 09 히어로 문구 — 하늘 왼쪽 위(어두운 쪽)에 둔다
.au__hero {
position: absolute;
left: $sp-4;
top: $sp-4;
max-width: 58%;
pointer-events: none;
}
.au__kicker {
margin: 0 0 $sp-1;
color: $subject-cream;
font-weight: 600;
font-size: 11px;
letter-spacing: .04em;
}
.au__title {
margin: 0;
color: $subject-cream;
font-weight: 700;
font-size: clamp(15px, 5vw, 26px);
line-height: 1.2;
word-break: keep-all;
text-shadow: 0 1px 8px rgba(8, 10, 24, .8);
}
@media (max-width: 340px) {
.au__hero { left: $sp-3; top: $sp-3; }
.au__kicker { font-size: 10px; }
}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);
}
.au {
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;
}
.au:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.au__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-y;
}
.au__pause {
position: absolute;
top: 8px;
right: 8px;
margin: 0;
padding: 4px 12px;
border: 1px solid rgba(255, 247, 230, 0.45);
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.au__pause:hover {
background: rgba(23, 20, 26, 0.92);
}
.au__pause:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.au__pause[hidden] {
display: none;
}
.au__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 54%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.au__status {
font-size: 10px;
padding: 4px 8px;
}
.au__pause {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.au__pause {
transition: none;
}
}
.au__hero {
position: absolute;
left: 16px;
top: 16px;
max-width: 58%;
pointer-events: none;
}
.au__kicker {
margin: 0 0 4px;
color: #fff7e6;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.04em;
}
.au__title {
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(15px, 5vw, 26px);
line-height: 1.2;
word-break: keep-all;
text-shadow: 0 1px 8px rgba(8, 10, 24, 0.8);
}
@media (max-width: 340px) {
.au__hero {
left: 12px;
top: 12px;
}
.au__kicker {
font-size: 10px;
}
}(function () {
const root = document.getElementById("au");
if (!root) return;
// 장면 좌표 — 캔버스 내부는 이 크기 x 배율(dpr)로 잡고, 그림은 늘 480x300 좌표로 그린다
const W = 480;
const H = 300;
// 고정 틱(ms) — 화면 주사율과 상관없이 같은 빠르기로 흐른다
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 동작 줄이기에서 멈춰 둘 장면 시각(ms) — 이 시각의 한 장면만 그린다
const STILL_MS = 5200;
// 밤하늘 — 위는 짙은 남색, 지평선 쪽은 한 단계 밝은 남보라
const SKY_TOP = [8, 10, 24];
const SKY_LOW = [24, 28, 60];
// 오로라 빛 — 채도를 낮춘 파스텔이지만 어두운 하늘과의 명도 차는 크게 둔다
const MINT = [150, 240, 200];
const TEAL = [126, 214, 236];
const VIOLET = [192, 168, 255];
const PINK = [255, 178, 214];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 부드러운 계단 3x² - 2x³ — 0 과 1 에서 기울기가 0 이라 이음매가 보이지 않는다
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 rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(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;
};
}
// 격자점 해시 — 정수 좌표 (ix, iy) 와 시드에서 0..1 값 하나를 만든다. 같은 점은 언제나 같은 값
function hash2(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1013904223)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 정수 격자점마다 해시 값을 두고, 칸 안에서는 네 모서리 값을 부드러운 계단으로 보간한다. 결과 0..1
function noise2(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 옥타브 합 — 주파수를 두 배로, 세기를 절반으로 줄여 가며 n 겹 더한 뒤 세기 합으로 나눈다. 결과 0..1
function octaves(x, y, n, seed) {
let sum = 0;
let amp = 0.5;
let norm = 0;
let f = 1;
for (let k = 0; k < n; k += 1) {
sum += amp * noise2(x * f + k * 17.13, y * f + k * 9.71, seed + k * 31);
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function textOf(el) { return (el.textContent || '').trim(); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const pauseBtn = pauseEl;
// 화면 글자는 DOM 에 있다 — 문구를 바꾸면 버튼·상태 줄도 같이 바뀐다
const STATUS0 = textOf(status);
const PAUSE_LABEL = textOf(pauseBtn);
const RESUME_LABEL = '動きを再開する';
const TXT_PAUSED = '止めました — ボタンで再開できます';
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 사람이 멈춤 버튼을 눌렀는가
let paused = false;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 캔버스 배율 — 화면 배율을 따르되 2 에서 자른다(배율 3 기기에서 픽셀 일이 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
}
function begin() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
// 작은 픽셀 버퍼 — 계산은 이 크기에서 하고 화면에는 늘려 그린다
function makeBuffer(w, h) {
const b = document.createElement('canvas');
b.width = w;
b.height = h;
const c = b.getContext('2d');
return { cv: b, c: c, img: c.createImageData(w, h), w: w, h: h };
}
// 버퍼를 무대 크기로 키워 더한다 — 늘릴 때 브라우저의 이미지 보간이 칸 사이를 메우고, 'lighter' 는 밤하늘 위에 빛을 더한다
function blitBuffer(b, x, y, w, h) {
b.c.putImageData(b.img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(b.cv, x, y, w, h);
ctx.restore();
}
// 높이 y 에서 하늘 색 — 버퍼 계산에서 밝기를 잴 때도 같은 식을 쓴다
function skyAt(y, h) { return mix(SKY_TOP, SKY_LOW, clamp(y / h, 0, 1)); }
function drawSky(c, w, h) {
const g = c.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
c.fillStyle = g;
c.fillRect(0, 0, w, h);
}
// 장면 시계(ms) — rAF 루프가 고정 틱으로만 늘린다. 동작 줄이기에서는 STILL_MS 에 멈춘다
let simMs = 0;
function nowS() { return simMs / 1000; }
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) / Math.max(1, r.width) * W, (ev.clientY - r.top) / Math.max(1, r.height) * H];
}
// 겹치는 DOM 층의 자리 — 장면 좌표를 무대 백분율로 바꿔 넣는다
function place(el, r) {
el.style.left = (r.x / W * 100).toFixed(3) + '%';
el.style.top = (r.y / H * 100).toFixed(3) + '%';
el.style.width = (r.w / W * 100).toFixed(3) + '%';
el.style.height = (r.h / H * 100).toFixed(3) + '%';
}
// 커튼 겹 수 — 겹마다 중심선 높이·노이즈 시드·밝기가 다르다
const CURTAIN_LAYERS = 3;
// 노이즈 결 — 장면 1px 당 노이즈 좌표가 움직이는 양. 작을수록 주름이 넓고 느긋하다
const NOISE_SCALE = 0.004;
// 세로 감쇠(1px 당) — 중심선에서 위로 d px 올라간 곳의 알파 = exp(-RAY_FALLOFF x d)
const RAY_FALLOFF = 0.018;
// 세로 줄 간격(px) — 줄마다 세로 그라데이션 하나
const STROKE_STEP = 3;
// 중심선 높이 — 옥타브 합(3겹) 값 노이즈 0..1 을 -1..1 로 펴서 진폭을 곱한다. 시간은 노이즈 둘째 좌표로 흘린다
function curtainCenter(o, layer, x, t) {
const n = octaves(x * NOISE_SCALE + t * o.speed * 0.4, t * o.speed + layer * 3.7, 3, o.seed + layer * 11);
return o.base - layer * 16 + (n - 0.5) * 2 * o.amp;
}
// 주름 밝기 — 결을 2.6배 잘게 읽은 노이즈를 문턱 0.25 위로 펴고 1.8 제곱해 밝은 주름과 어두운 틈을 가른다
function curtainFold(o, layer, x, t) {
const n = octaves(x * NOISE_SCALE * 2.6 - t * o.speed * 0.9, layer * 5.1 + t * o.speed * 0.5, 2, o.seed + 50 + layer);
return Math.pow(smooth((n - 0.25) / 0.6), 1.8);
}
// 커튼 — 겹마다 STROKE_STEP 간격으로 세로 줄을 긋는다. 줄의 아래 끝이 중심선, 위로 갈수록 지수로 옅어진다
function drawCurtain(c, o, t, w) {
c.save();
c.globalCompositeOperation = 'lighter';
for (let layer = 0; layer < CURTAIN_LAYERS; layer += 1) {
const k = CURTAIN_LAYERS > 1 ? layer / (CURTAIN_LAYERS - 1) : 0;
const layerAlpha = o.alpha * (1 - 0.35 * k);
for (let x = -STROKE_STEP; x <= w + STROKE_STEP; x += STROKE_STEP) {
const fold = curtainFold(o, layer, x, t);
if (fold < 0.02) {
continue;
}
const yc = curtainCenter(o, layer, x, t);
const len = o.len * (0.55 + 0.75 * fold) * (1 - 0.25 * k);
const top = yc - len;
const g = c.createLinearGradient(0, yc + 6, 0, top);
g.addColorStop(0, rgba(o.low, 0));
// 아래 가장자리 6px — 짧게 밝아져 커튼 밑단이 또렷하다
g.addColorStop(6 / (len + 6), rgba(o.low, layerAlpha * fold));
for (let s = 1; s <= 4; s += 1) {
const d = len * s / 4;
g.addColorStop((6 + d) / (len + 6), rgba(mix(o.low, o.high, s / 4), layerAlpha * fold * Math.exp(-RAY_FALLOFF * d)));
}
c.fillStyle = g;
c.fillRect(x, top, STROKE_STEP + 0.6, len + 6);
}
}
c.restore();
}
// 물결 진폭(px) — 화면 맨 아래 줄(물가에서 가장 먼 줄)의 옆 밀림. 물가 쪽은 이 값의 0.25배
const RIPPLE_AMP = 3;
// 물결 촘촘함 — 물가에서 1px 내려갈 때 사인 위상이 바뀌는 양(라디안)
const RIPPLE_FREQ = 0.32;
// 깊이 어둡게 — 화면 맨 아래 줄을 덮는 어두운 막의 알파(물가는 0.08)
const REFLECT_DIM = 0.55;
// 물결이 흐르는 빠르기(rad/s)
const RIPPLE_SPEED = 2.6;
// 반사를 옮겨 그리는 줄 높이(px)
const REFLECT_ROW = 2;
const WATER = [6, 8, 20];
// 반사 — 하늘 그림(src, 높이 horizon)을 거꾸로 읽어 물 위에 줄 단위로 옮긴다. 줄마다 사인 물결만큼 옆으로 민다
function drawReflection(c, src, horizon, t) {
const waterH = H - horizon;
const sd = src.width / W;
c.save();
c.fillStyle = rgba(WATER, 1);
c.fillRect(0, horizon, W, waterH);
for (let y = 0; y < waterH; y += REFLECT_ROW) {
const depth = y / waterH;
// 원근 — 가까운(아래) 물일수록 물결이 크고 성기다
const amp = RIPPLE_AMP * (0.25 + 0.75 * depth);
const ph = y * RIPPLE_FREQ * (1 - 0.45 * depth);
const dx = amp * Math.sin(ph + t * RIPPLE_SPEED) + amp * 0.45 * Math.sin(ph * 2.3 - t * RIPPLE_SPEED * 1.4);
// 거울 — 물가에서 y 만큼 내려간 줄은 하늘에서 y 만큼 위의 줄을 비춘다
const sy = clamp(horizon - REFLECT_ROW - y, 0, horizon - REFLECT_ROW);
c.globalAlpha = 0.88 - 0.3 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx, horizon + y, W, REFLECT_ROW);
// 흐리게 — 깊을수록 옆으로 벌린 사본 두 장을 반투명으로 더 겹친다
const spread = 1 + 4 * depth;
c.globalAlpha = 0.22 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx - spread, horizon + y, W, REFLECT_ROW);
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx + spread, horizon + y, W, REFLECT_ROW);
}
c.globalAlpha = 1;
// 깊이별 어둡게 — 물가 0.08 에서 화면 아래 REFLECT_DIM 까지
const g = c.createLinearGradient(0, horizon, 0, H);
g.addColorStop(0, rgba(WATER, 0.08));
g.addColorStop(1, rgba(WATER, REFLECT_DIM));
c.fillStyle = g;
c.fillRect(0, horizon, W, waterH);
c.restore();
}
// 능선 겹 수 — 먼 겹부터 그린다
const RIDGE_LAYERS = 3;
// 거칠기 — 중점 흔들기 단계마다 흔들림 폭에 곱하는 값. 클수록 능선이 잘게 들쭉날쭉하다
const RIDGE_ROUGHNESS = 0.55;
// 시차 따라가기 — 틱마다 목표 밀림까지 남은 거리의 이 비율만큼 간다
const PARALLAX_LERP = 0.08;
// 능선 점 수 — 2⁶ + 1 (중점을 여섯 번 나눈다)
const RIDGE_POINTS = 65;
// 능선이 화면 양옆으로 남는 폭(px) — 밀려도 끝이 보이지 않게
const RIDGE_MARGIN = 40;
const HORIZON_09 = 196;
// 겹마다 바닥 높이·흔들림 폭·최대 밀림(px)·색 — 가까울수록 많이 밀리고 짙다
const RIDGE_BASE = [150, 168, 186];
const RIDGE_AMP = [70, 52, 36];
const RIDGE_DEPTH_PX = [6, 14, 26];
const RIDGE_RGB = [[52, 58, 104], [30, 34, 70], [14, 16, 38]];
// 하늘 커튼은 능선보다 더 멀다 — 밀림 3px
const SKY_DEPTH_PX = 3;
const CURTAIN_09 = { base: 140, amp: 34, len: 118, low: MINT, high: VIOLET, alpha: 0.5, speed: 0.22, seed: 909 };
const rng09 = makeRng(909);
// 중점 흔들기 — 양 끝 높이에서 시작해 구간 가운데 점을 두 끝 평균 ± 흔들림으로 정하고, 단계마다 흔들림 폭을 RIDGE_ROUGHNESS 배로 줄인다
function midpointRidge(rng, amp) {
const n = RIDGE_POINTS;
const a = new Float32Array(n);
a[0] = (rng() - 0.5) * amp;
a[n - 1] = (rng() - 0.5) * amp;
let step = n - 1;
let spread = amp;
while (step > 1) {
const half = step / 2;
for (let i = half; i < n; i += step) {
a[i] = (a[i - half] + a[i + half]) / 2 + (rng() - 0.5) * spread;
}
step = half;
spread *= RIDGE_ROUGHNESS;
}
return a;
}
const ridges = [];
for (let k = 0; k < RIDGE_LAYERS; k += 1) {
ridges.push(midpointRidge(rng09, RIDGE_AMP[k]));
}
// 하늘의 작은 별 — 시드 고정 자리
const SKY_STARS = [];
for (let i = 0; i < 70; i += 1) {
SKY_STARS.push(rng09() * W, rng09() * 150, 0.3 + rng09() * 0.6);
}
const vpPhase09 = rng09() * TAU;
const sky09 = document.createElement('canvas');
const skyCtx09 = sky09.getContext('2d');
// 시차 — off 는 지금 밀림(-1..1), offTarget 은 포인터가 정한 목표
let off = 0;
let offTarget = 0;
let live09 = false;
let keyLive09 = false;
let ptrX09 = W / 2;
function virtualX09(t) { return W / 2 + 200 * Math.sin(t * 1.5 + vpPhase09); }
function sizeSky09() {
const w = Math.round(W * dpr);
const h = Math.round(HORIZON_09 * dpr);
if (sky09.width !== w || sky09.height !== h) {
sky09.width = w;
sky09.height = h;
}
}
function drawRidge09(c, k) {
const a = ridges[k];
const shift = -off * RIDGE_DEPTH_PX[k];
const span = W + RIDGE_MARGIN * 2;
c.beginPath();
c.moveTo(-RIDGE_MARGIN + shift, HORIZON_09);
for (let i = 0; i < RIDGE_POINTS; i += 1) {
c.lineTo(-RIDGE_MARGIN + shift + span * i / (RIDGE_POINTS - 1), RIDGE_BASE[k] + a[i]);
}
c.lineTo(W + RIDGE_MARGIN + shift, HORIZON_09);
c.closePath();
c.fillStyle = rgba(RIDGE_RGB[k], 1);
c.fill();
}
function itemInit() { sizeSky09(); }
function itemStep(dt) {
const x = live09 ? ptrX09 : virtualX09(nowS());
offTarget = clamp((x - W / 2) / (W / 2), -1, 1);
off += (offTarget - off) * PARALLAX_LERP;
}
function itemDraw() {
const t = nowS();
sizeSky09();
const c = skyCtx09;
c.setTransform(dpr, 0, 0, dpr, 0, 0);
drawSky(c, W, HORIZON_09);
c.fillStyle = 'rgba(246,244,255,0.7)';
for (let i = 0; i < SKY_STARS.length; i += 3) {
c.fillRect(SKY_STARS[i] - off * SKY_DEPTH_PX, SKY_STARS[i + 1], SKY_STARS[i + 2] * 2, SKY_STARS[i + 2] * 2);
}
c.save();
c.translate(-off * SKY_DEPTH_PX, 0);
drawCurtain(c, CURTAIN_09, t, W + SKY_DEPTH_PX * 2);
c.restore();
for (let k = 0; k < RIDGE_LAYERS; k += 1) {
drawRidge09(c, k);
}
ctx.drawImage(sky09, 0, 0, W, HORIZON_09);
drawReflection(ctx, sky09, HORIZON_09, t);
}
function itemDemo(t) { }
function itemStatus() { return STATUS0; }
function itemSnap() {
if (live09) {
offTarget = clamp((ptrX09 - W / 2) / (W / 2), -1, 1);
}
off = offTarget;
}
function itemPointer(p, on) {
if (on) {
live09 = true;
keyLive09 = false;
ptrX09 = p[0];
return true;
}
if (!keyLive09) {
live09 = false;
}
return false;
}
// 화살표 키 — 시점을 30px 씩 옆으로 옮긴다
function itemKey(key) {
const d = key === 'ArrowLeft' ? -30 : key === 'ArrowRight' ? 30 : 0;
if (d === 0) {
return false;
}
if (!live09) {
ptrX09 = virtualX09(nowS());
}
live09 = true;
keyLive09 = true;
ptrX09 = clamp(ptrX09 + d, 0, W);
return true;
}
function render() {
begin();
itemDraw();
if (!paused) {
say(itemStatus());
}
}
// 루프가 멈춰 있을 때(동작 줄이기·멈춤) 입력이 들어오면 — 움직이던 값을 목표로 바로 옮겨 한 장 그린다
function after() {
if (rafId) {
return;
}
itemSnap();
render();
}
function setPaused(p) {
paused = p;
pauseBtn.setAttribute('aria-pressed', p ? 'true' : 'false');
pauseBtn.textContent = p ? RESUME_LABEL : PAUSE_LABEL;
if (p) {
stopLoop();
say(TXT_PAUSED);
}
else {
say(itemStatus());
startLoop();
}
}
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); });
cv.addEventListener('pointermove', function (ev) {
if (itemPointer(toScene(ev), true)) {
wake();
after();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
if (itemPointer(toScene(ev), true)) {
after();
}
});
cv.addEventListener('pointerleave', function () { itemPointer([W / 2, H / 2], false); });
root.addEventListener('keydown', function (ev) {
if (ev.target !== root) {
return;
}
if (itemKey(ev.key)) {
ev.preventDefault();
wake();
after();
}
});
function onResize() {
fitCanvas();
render();
}
fitCanvas();
itemInit();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
simMs += FIXED_DT;
if (demo) {
itemDemo(simMs);
}
itemStep(FIXED_DT);
acc -= FIXED_DT;
n += 1;
}
if (n === 6) {
acc = 0;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId || still || paused) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 순간 한 장면으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
pauseBtn.hidden = still;
if (still) {
stopLoop();
itemSnap();
render();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
pauseBtn.hidden = still;
if (still) {
// 동작 줄이기 — 노이즈 시간을 STILL_MS 에 세운 한 장면. 입력은 after() 가 그 자리에서 반영한다
simMs = STILL_MS;
itemSnap();
render();
}
else {
render();
startLoop();
}
return function () {
stopLoop();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// RidgeLakeParallaxHero - vanilla 판(09-aur-ridge-lake-parallax-hero) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RidgeLakeParallaxHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RidgeLakeParallaxHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
type Rect = { x: number; y: number; w: number; h: number };
type PixelBuffer = { cv: HTMLCanvasElement; c: CanvasRenderingContext2D; img: ImageData; w: number; h: number };
// 장면 좌표 — 캔버스 내부는 이 크기 x 배율(dpr)로 잡고, 그림은 늘 480x300 좌표로 그린다
const W = 480;
const H = 300;
// 고정 틱(ms) — 화면 주사율과 상관없이 같은 빠르기로 흐른다
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 동작 줄이기에서 멈춰 둘 장면 시각(ms) — 이 시각의 한 장면만 그린다
const STILL_MS = 5200;
// 밤하늘 — 위는 짙은 남색, 지평선 쪽은 한 단계 밝은 남보라
const SKY_TOP: RGB = [8, 10, 24];
const SKY_LOW: RGB = [24, 28, 60];
// 오로라 빛 — 채도를 낮춘 파스텔이지만 어두운 하늘과의 명도 차는 크게 둔다
const MINT: RGB = [150, 240, 200];
const TEAL: RGB = [126, 214, 236];
const VIOLET: RGB = [192, 168, 255];
const PINK: RGB = [255, 178, 214];
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; }
// 부드러운 계단 3x² - 2x³ — 0 과 1 에서 기울기가 0 이라 이음매가 보이지 않는다
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(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;
};
}
// 격자점 해시 — 정수 좌표 (ix, iy) 와 시드에서 0..1 값 하나를 만든다. 같은 점은 언제나 같은 값
function hash2(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1013904223)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 정수 격자점마다 해시 값을 두고, 칸 안에서는 네 모서리 값을 부드러운 계단으로 보간한다. 결과 0..1
function noise2(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 옥타브 합 — 주파수를 두 배로, 세기를 절반으로 줄여 가며 n 겹 더한 뒤 세기 합으로 나눈다. 결과 0..1
function octaves(x: number, y: number, n: number, seed: number): number {
let sum = 0;
let amp = 0.5;
let norm = 0;
let f = 1;
for (let k = 0; k < n; k += 1) {
sum += amp * noise2(x * f + k * 17.13, y * f + k * 9.71, seed + k * 31);
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function textOf(el: HTMLElement): string { return (el.textContent || '').trim(); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const pauseBtn: HTMLButtonElement = pauseEl;
// 화면 글자는 DOM 에 있다 — 문구를 바꾸면 버튼·상태 줄도 같이 바뀐다
const STATUS0 = textOf(status);
const PAUSE_LABEL = textOf(pauseBtn);
const RESUME_LABEL = '움직임 다시 켜기';
const TXT_PAUSED = '멈췄어요 — 버튼으로 다시 켜기';
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 사람이 멈춤 버튼을 눌렀는가
let paused = false;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 캔버스 배율 — 화면 배율을 따르되 2 에서 자른다(배율 3 기기에서 픽셀 일이 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas(): void {
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
}
function begin(): void {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
// 작은 픽셀 버퍼 — 계산은 이 크기에서 하고 화면에는 늘려 그린다
function makeBuffer(w: number, h: number): PixelBuffer {
const b = document.createElement('canvas');
b.width = w;
b.height = h;
const c = b.getContext('2d') as CanvasRenderingContext2D;
return { cv: b, c: c, img: c.createImageData(w, h), w: w, h: h };
}
// 버퍼를 무대 크기로 키워 더한다 — 늘릴 때 브라우저의 이미지 보간이 칸 사이를 메우고, 'lighter' 는 밤하늘 위에 빛을 더한다
function blitBuffer(b: PixelBuffer, x: number, y: number, w: number, h: number): void {
b.c.putImageData(b.img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(b.cv, x, y, w, h);
ctx.restore();
}
// 높이 y 에서 하늘 색 — 버퍼 계산에서 밝기를 잴 때도 같은 식을 쓴다
function skyAt(y: number, h: number): RGB { return mix(SKY_TOP, SKY_LOW, clamp(y / h, 0, 1)); }
function drawSky(c: CanvasRenderingContext2D, w: number, h: number): void {
const g = c.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
c.fillStyle = g;
c.fillRect(0, 0, w, h);
}
// 장면 시계(ms) — rAF 루프가 고정 틱으로만 늘린다. 동작 줄이기에서는 STILL_MS 에 멈춘다
let simMs = 0;
function nowS(): number { return simMs / 1000; }
function toScene(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) / Math.max(1, r.width) * W, (ev.clientY - r.top) / Math.max(1, r.height) * H];
}
// 겹치는 DOM 층의 자리 — 장면 좌표를 무대 백분율로 바꿔 넣는다
function place(el: HTMLElement, r: Rect): void {
el.style.left = (r.x / W * 100).toFixed(3) + '%';
el.style.top = (r.y / H * 100).toFixed(3) + '%';
el.style.width = (r.w / W * 100).toFixed(3) + '%';
el.style.height = (r.h / H * 100).toFixed(3) + '%';
}
// 커튼 겹 수 — 겹마다 중심선 높이·노이즈 시드·밝기가 다르다
const CURTAIN_LAYERS = 3;
// 노이즈 결 — 장면 1px 당 노이즈 좌표가 움직이는 양. 작을수록 주름이 넓고 느긋하다
const NOISE_SCALE = 0.004;
// 세로 감쇠(1px 당) — 중심선에서 위로 d px 올라간 곳의 알파 = exp(-RAY_FALLOFF x d)
const RAY_FALLOFF = 0.018;
// 세로 줄 간격(px) — 줄마다 세로 그라데이션 하나
const STROKE_STEP = 3;
type Curtain = { base: number; amp: number; len: number; low: RGB; high: RGB; alpha: number; speed: number; seed: number };
// 중심선 높이 — 옥타브 합(3겹) 값 노이즈 0..1 을 -1..1 로 펴서 진폭을 곱한다. 시간은 노이즈 둘째 좌표로 흘린다
function curtainCenter(o: Curtain, layer: number, x: number, t: number): number {
const n = octaves(x * NOISE_SCALE + t * o.speed * 0.4, t * o.speed + layer * 3.7, 3, o.seed + layer * 11);
return o.base - layer * 16 + (n - 0.5) * 2 * o.amp;
}
// 주름 밝기 — 결을 2.6배 잘게 읽은 노이즈를 문턱 0.25 위로 펴고 1.8 제곱해 밝은 주름과 어두운 틈을 가른다
function curtainFold(o: Curtain, layer: number, x: number, t: number): number {
const n = octaves(x * NOISE_SCALE * 2.6 - t * o.speed * 0.9, layer * 5.1 + t * o.speed * 0.5, 2, o.seed + 50 + layer);
return Math.pow(smooth((n - 0.25) / 0.6), 1.8);
}
// 커튼 — 겹마다 STROKE_STEP 간격으로 세로 줄을 긋는다. 줄의 아래 끝이 중심선, 위로 갈수록 지수로 옅어진다
function drawCurtain(c: CanvasRenderingContext2D, o: Curtain, t: number, w: number): void {
c.save();
c.globalCompositeOperation = 'lighter';
for (let layer = 0; layer < CURTAIN_LAYERS; layer += 1) {
const k = CURTAIN_LAYERS > 1 ? layer / (CURTAIN_LAYERS - 1) : 0;
const layerAlpha = o.alpha * (1 - 0.35 * k);
for (let x = -STROKE_STEP; x <= w + STROKE_STEP; x += STROKE_STEP) {
const fold = curtainFold(o, layer, x, t);
if (fold < 0.02) { continue; }
const yc = curtainCenter(o, layer, x, t);
const len = o.len * (0.55 + 0.75 * fold) * (1 - 0.25 * k);
const top = yc - len;
const g = c.createLinearGradient(0, yc + 6, 0, top);
g.addColorStop(0, rgba(o.low, 0));
// 아래 가장자리 6px — 짧게 밝아져 커튼 밑단이 또렷하다
g.addColorStop(6 / (len + 6), rgba(o.low, layerAlpha * fold));
for (let s = 1; s <= 4; s += 1) {
const d = len * s / 4;
g.addColorStop((6 + d) / (len + 6), rgba(mix(o.low, o.high, s / 4), layerAlpha * fold * Math.exp(-RAY_FALLOFF * d)));
}
c.fillStyle = g;
c.fillRect(x, top, STROKE_STEP + 0.6, len + 6);
}
}
c.restore();
}
// 물결 진폭(px) — 화면 맨 아래 줄(물가에서 가장 먼 줄)의 옆 밀림. 물가 쪽은 이 값의 0.25배
const RIPPLE_AMP = 3;
// 물결 촘촘함 — 물가에서 1px 내려갈 때 사인 위상이 바뀌는 양(라디안)
const RIPPLE_FREQ = 0.32;
// 깊이 어둡게 — 화면 맨 아래 줄을 덮는 어두운 막의 알파(물가는 0.08)
const REFLECT_DIM = 0.55;
// 물결이 흐르는 빠르기(rad/s)
const RIPPLE_SPEED = 2.6;
// 반사를 옮겨 그리는 줄 높이(px)
const REFLECT_ROW = 2;
const WATER: RGB = [6, 8, 20];
// 반사 — 하늘 그림(src, 높이 horizon)을 거꾸로 읽어 물 위에 줄 단위로 옮긴다. 줄마다 사인 물결만큼 옆으로 민다
function drawReflection(c: CanvasRenderingContext2D, src: HTMLCanvasElement, horizon: number, t: number): void {
const waterH = H - horizon;
const sd = src.width / W;
c.save();
c.fillStyle = rgba(WATER, 1);
c.fillRect(0, horizon, W, waterH);
for (let y = 0; y < waterH; y += REFLECT_ROW) {
const depth = y / waterH;
// 원근 — 가까운(아래) 물일수록 물결이 크고 성기다
const amp = RIPPLE_AMP * (0.25 + 0.75 * depth);
const ph = y * RIPPLE_FREQ * (1 - 0.45 * depth);
const dx = amp * Math.sin(ph + t * RIPPLE_SPEED) + amp * 0.45 * Math.sin(ph * 2.3 - t * RIPPLE_SPEED * 1.4);
// 거울 — 물가에서 y 만큼 내려간 줄은 하늘에서 y 만큼 위의 줄을 비춘다
const sy = clamp(horizon - REFLECT_ROW - y, 0, horizon - REFLECT_ROW);
c.globalAlpha = 0.88 - 0.3 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx, horizon + y, W, REFLECT_ROW);
// 흐리게 — 깊을수록 옆으로 벌린 사본 두 장을 반투명으로 더 겹친다
const spread = 1 + 4 * depth;
c.globalAlpha = 0.22 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx - spread, horizon + y, W, REFLECT_ROW);
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx + spread, horizon + y, W, REFLECT_ROW);
}
c.globalAlpha = 1;
// 깊이별 어둡게 — 물가 0.08 에서 화면 아래 REFLECT_DIM 까지
const g = c.createLinearGradient(0, horizon, 0, H);
g.addColorStop(0, rgba(WATER, 0.08));
g.addColorStop(1, rgba(WATER, REFLECT_DIM));
c.fillStyle = g;
c.fillRect(0, horizon, W, waterH);
c.restore();
}
// 능선 겹 수 — 먼 겹부터 그린다
const RIDGE_LAYERS = 3;
// 거칠기 — 중점 흔들기 단계마다 흔들림 폭에 곱하는 값. 클수록 능선이 잘게 들쭉날쭉하다
const RIDGE_ROUGHNESS = 0.55;
// 시차 따라가기 — 틱마다 목표 밀림까지 남은 거리의 이 비율만큼 간다
const PARALLAX_LERP = 0.08;
// 능선 점 수 — 2⁶ + 1 (중점을 여섯 번 나눈다)
const RIDGE_POINTS = 65;
// 능선이 화면 양옆으로 남는 폭(px) — 밀려도 끝이 보이지 않게
const RIDGE_MARGIN = 40;
const HORIZON_09 = 196;
// 겹마다 바닥 높이·흔들림 폭·최대 밀림(px)·색 — 가까울수록 많이 밀리고 짙다
const RIDGE_BASE: number[] = [150, 168, 186];
const RIDGE_AMP: number[] = [70, 52, 36];
const RIDGE_DEPTH_PX: number[] = [6, 14, 26];
const RIDGE_RGB: RGB[] = [[52, 58, 104], [30, 34, 70], [14, 16, 38]];
// 하늘 커튼은 능선보다 더 멀다 — 밀림 3px
const SKY_DEPTH_PX = 3;
const CURTAIN_09: Curtain = { base: 140, amp: 34, len: 118, low: MINT, high: VIOLET, alpha: 0.5, speed: 0.22, seed: 909 };
const rng09 = makeRng(909);
// 중점 흔들기 — 양 끝 높이에서 시작해 구간 가운데 점을 두 끝 평균 ± 흔들림으로 정하고, 단계마다 흔들림 폭을 RIDGE_ROUGHNESS 배로 줄인다
function midpointRidge(rng: () => number, amp: number): Float32Array {
const n = RIDGE_POINTS;
const a = new Float32Array(n);
a[0] = (rng() - 0.5) * amp;
a[n - 1] = (rng() - 0.5) * amp;
let step = n - 1;
let spread = amp;
while (step > 1) {
const half = step / 2;
for (let i = half; i < n; i += step) {
a[i] = (a[i - half] + a[i + half]) / 2 + (rng() - 0.5) * spread;
}
step = half;
spread *= RIDGE_ROUGHNESS;
}
return a;
}
const ridges: Float32Array[] = [];
for (let k = 0; k < RIDGE_LAYERS; k += 1) { ridges.push(midpointRidge(rng09, RIDGE_AMP[k])); }
// 하늘의 작은 별 — 시드 고정 자리
const SKY_STARS: number[] = [];
for (let i = 0; i < 70; i += 1) { SKY_STARS.push(rng09() * W, rng09() * 150, 0.3 + rng09() * 0.6); }
const vpPhase09 = rng09() * TAU;
const sky09: HTMLCanvasElement = document.createElement('canvas');
const skyCtx09 = sky09.getContext('2d') as CanvasRenderingContext2D;
// 시차 — off 는 지금 밀림(-1..1), offTarget 은 포인터가 정한 목표
let off = 0;
let offTarget = 0;
let live09 = false;
let keyLive09 = false;
let ptrX09 = W / 2;
function virtualX09(t: number): number { return W / 2 + 200 * Math.sin(t * 1.5 + vpPhase09); }
function sizeSky09(): void {
const w = Math.round(W * dpr);
const h = Math.round(HORIZON_09 * dpr);
if (sky09.width !== w || sky09.height !== h) {
sky09.width = w;
sky09.height = h;
}
}
function drawRidge09(c: CanvasRenderingContext2D, k: number): void {
const a = ridges[k];
const shift = -off * RIDGE_DEPTH_PX[k];
const span = W + RIDGE_MARGIN * 2;
c.beginPath();
c.moveTo(-RIDGE_MARGIN + shift, HORIZON_09);
for (let i = 0; i < RIDGE_POINTS; i += 1) {
c.lineTo(-RIDGE_MARGIN + shift + span * i / (RIDGE_POINTS - 1), RIDGE_BASE[k] + a[i]);
}
c.lineTo(W + RIDGE_MARGIN + shift, HORIZON_09);
c.closePath();
c.fillStyle = rgba(RIDGE_RGB[k], 1);
c.fill();
}
function itemInit(): void { sizeSky09(); }
function itemStep(dt: number): void {
const x = live09 ? ptrX09 : virtualX09(nowS());
offTarget = clamp((x - W / 2) / (W / 2), -1, 1);
off += (offTarget - off) * PARALLAX_LERP;
}
function itemDraw(): void {
const t = nowS();
sizeSky09();
const c = skyCtx09;
c.setTransform(dpr, 0, 0, dpr, 0, 0);
drawSky(c, W, HORIZON_09);
c.fillStyle = 'rgba(246,244,255,0.7)';
for (let i = 0; i < SKY_STARS.length; i += 3) { c.fillRect(SKY_STARS[i] - off * SKY_DEPTH_PX, SKY_STARS[i + 1], SKY_STARS[i + 2] * 2, SKY_STARS[i + 2] * 2); }
c.save();
c.translate(-off * SKY_DEPTH_PX, 0);
drawCurtain(c, CURTAIN_09, t, W + SKY_DEPTH_PX * 2);
c.restore();
for (let k = 0; k < RIDGE_LAYERS; k += 1) { drawRidge09(c, k); }
ctx.drawImage(sky09, 0, 0, W, HORIZON_09);
drawReflection(ctx, sky09, HORIZON_09, t);
}
function itemDemo(t: number): void { }
function itemStatus(): string { return STATUS0; }
function itemSnap(): void {
if (live09) { offTarget = clamp((ptrX09 - W / 2) / (W / 2), -1, 1); }
off = offTarget;
}
function itemPointer(p: Pt, on: boolean): boolean {
if (on) {
live09 = true;
keyLive09 = false;
ptrX09 = p[0];
return true;
}
if (!keyLive09) { live09 = false; }
return false;
}
// 화살표 키 — 시점을 30px 씩 옆으로 옮긴다
function itemKey(key: string): boolean {
const d = key === 'ArrowLeft' ? -30 : key === 'ArrowRight' ? 30 : 0;
if (d === 0) { return false; }
if (!live09) { ptrX09 = virtualX09(nowS()); }
live09 = true;
keyLive09 = true;
ptrX09 = clamp(ptrX09 + d, 0, W);
return true;
}
function render(): void {
begin();
itemDraw();
if (!paused) { say(itemStatus()); }
}
// 루프가 멈춰 있을 때(동작 줄이기·멈춤) 입력이 들어오면 — 움직이던 값을 목표로 바로 옮겨 한 장 그린다
function after(): void {
if (rafId) { return; }
itemSnap();
render();
}
function setPaused(p: boolean): void {
paused = p;
pauseBtn.setAttribute('aria-pressed', p ? 'true' : 'false');
pauseBtn.textContent = p ? RESUME_LABEL : PAUSE_LABEL;
if (p) {
stopLoop();
say(TXT_PAUSED);
} else {
say(itemStatus());
startLoop();
}
}
pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); });
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (itemPointer(toScene(ev), true)) { wake(); after(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
if (itemPointer(toScene(ev), true)) { after(); }
});
cv.addEventListener('pointerleave', function (): void { itemPointer([W / 2, H / 2], false); });
root.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.target !== root) { return; }
if (itemKey(ev.key)) {
ev.preventDefault();
wake();
after();
}
});
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
itemInit();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
simMs += FIXED_DT;
if (demo) { itemDemo(simMs); }
itemStep(FIXED_DT);
acc -= FIXED_DT;
n += 1;
}
if (n === 6) { acc = 0; }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId || still || paused) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 순간 한 장면으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
pauseBtn.hidden = still;
if (still) {
stopLoop();
itemSnap();
render();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
pauseBtn.hidden = still;
if (still) {
// 동작 줄이기 — 노이즈 시간을 STILL_MS 에 세운 한 장면. 입력은 after() 가 그 자리에서 반영한다
simMs = STILL_MS;
itemSnap();
render();
} else {
render();
startLoop();
}
return function (): void {
stopLoop();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("au") + " is-demo"} ref={rootRef} tabIndex={0} role="group" aria-label="호숫가 풍경 — 화살표 키로 시점 옮기기">
<canvas className={cx("au__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("au__hero")}>
<p className={cx("au__kicker")}>밤 호숫가에서 보내는 사흘</p>
<h2 className={cx("au__title")}>오로라 아래에서 쉬어 가는 밤</h2>
</div>
<button className={cx("au__pause")} data-role="pause" type="button" aria-pressed="false">움직임 멈추기</button>
<p className={cx("au__status")} data-role="status" aria-live="off">포인터를 따라 능선이 깊이별로 움직여요</p>
</div>
);
}09 山の稜線・オーロラ・湖の映り込みをまとめた視差ヒーロー — 計測 累積面積 41.267% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · オーロラ 背景 9種 — カーテン・光の筋・湖の映り込み・稜線
このデモが属する記事
オーロラ 背景 9種 — カーテン・光の筋・湖の映り込み・稜線オーロラ 背景は、画像ファイルを使わずにキャンバスが毎フレーム光の帯をすべて計算して描く、ページの最初の画面に敷く動く夜空です。オーロラをグラデーション一枚でまねると、壁紙のように止まって見えます。ノイズをノイズでもう一度ゆがめると03番の この記事のデモをすべて見る


