css 影 おしゃれ 9パターン — 光の位置から影を計算する場面
css 影 おしゃれ に見せたくてbox-shadow一行で済ませると、光が回っても影は動かず、壁の裏まで光が漏れ、二つの光でも影は一つのまま — 01番・05番・08番が計算で直す。九つの場面はキャンバスで影を計算して描くので、ファイルごと貼ればそのまま動きます。
影の九つの場面をこのページで見る
下の格子は、ページを開いた時点から自動で動いています。光を動かすのは仮想の経路で、キャンバスを押すとそこからポインタと矢印キーが光を動かします。
01 shcast-point-light-planar-projection
02 shcast-pointer-light-pillar-row
03 shcast-height-penumbra-blur
04 shcast-sun-arc-sundial
05 shcast-wall-visibility-polygon
06 shcast-elevation-token-card-stack
07 shcast-contact-occlusion-darken
08 shcast-two-colored-lights-overlap
09 shcast-desk-lamp-dusk-hero-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 光が回ると床に投影される影
- 02 ポインターを光源にした柱の列
- 03 高さに応じてぼやける影
- 04 仮想の太陽が弧を描く日時計
- 05 壁に遮られる光の領域
- 06 層の高さから計算されるカードの影の値
- 07 物体が降りるほど濃くなる接触影
- 08 二色の光源が重なって作る二重の影
- 09 日が沈みランプが灯る机のヒーロー
css 影 おしゃれ 9パターン — 光から計算しないと影は動かない
九つの場面はどれも480×300(W・H)のキャンバス一枚に描き、入力は光の目標点ひとつだけです。場面は目標点を LIGHT_LERP の割合で追いかけ、毎フレーム影を最初から計算し直します。時間は16ミリ秒(FIXED_DT)ごとに区切って使い、キャンバスを押す前は仮想の経路が光を動かし、押したあとはポインタと矢印キーが動かします。
box-shadow の一行は、光がどこにあるかを知らない値です。css 影のつけ方の定番は box-shadow: 8px 12px 20px のようにオフセットを手で決める方法ですが、その数字は最初から固定なので、光が回っても影はついて回りません。九つの場面は、この数字を手で書かずに、光の位置と高さから取り出す形に変えました。
計算の骨組みは一行です。光(lx, ly)と高さ lh、物体の点(x, y)と高さ h があれば、床の影は 光 + (点 − 光) × lh ÷ (lh − h) の場所に落ちます。点が高いほど分母が小さくなって影は光の反対側へ遠く押され、分母は最小8に固定してゼロ割りを防いでいます。01・02・08・09 はこの式を箱の上面の四点に当てて凸包にまとめ、03・04・06・07 は同じ考えで高さからぼかし・長さ・濃さを取り出し、05 は投影のかわりに光線で光をさえぎります。
文字に値を重ねて立体にする側はcss text-shadow 9パターン — 影の値で立つ文字が扱い、この編は影の形そのものを光の位置から計算することに絞ります。同じ入力なら同じ絵が出ます。種を固定した乱数(02の柱の高さ)は使いますが、Math.random・new Date・fetch・WebGL・ライブラリは九つのファイルのどこにも使っていません。
技法別のコードで見る影
下の断片は各場面の index.html から連続した行をそのまま切り出したもので、最初から最後までつながったファイルは暗号化zipの中で開きます。場面ごとに makeScene が違い、入力・時間・ボタンを受け持つ main は九つとも同じ骨組みです。
01光が回ると床へ投影される影
castPoint が光の高さ220と箱の高さ70から倍率220÷(220−70)=1.47を求めて上面の四つの角を光の反対側の床へ押し出し、boxShadow が床の四点と押し出した四点を凸包で一つの塊にして塗ります。光が楕円の軌道(ORBIT_RADIUS 110)を一周すると、影はいつも反対側に立って一緒に回ります。測定は累積面積63.4%・平均強度40.4・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="光源の位置と物体の高さから角ごとに床の座標を計算し、光が回ると影が反対側へ回る。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">光源の位置と物体の高さから角ごとに床の座標を計算し、光が回ると影が反対側へ回る。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_HEIGHT = 220;
const ORBIT_RADIUS = 110;
const LIGHT_LERP = 0.2;
const BOX_HEIGHT = 70;
const BOX_RECT = [212, 140, 268, 178];
const CENTER_X = 240;
const CENTER_Y = 150;
const FLOOR_RGB = [243, 238, 250];
const SHADOW_RGB = [74, 56, 120];
const WARM_RGB = [255, 226, 150];
const LILAC_TOP = [196, 172, 255];
const LILAC_FRONT = [150, 120, 232];
function makeScene(ctx) {
let lx = CENTER_X + ORBIT_RADIUS;
let ly = CENTER_Y;
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [CENTER_X + ORBIT_RADIUS * Math.cos(a), CENTER_Y + ORBIT_RADIUS * 0.72 * Math.sin(a)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx, ty) { lx = tx; ly = ty; },
draw: function () {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 300, WARM_RGB, 0.95);
const poly = boxShadow(lx, ly, LIGHT_HEIGHT, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT);
fillShadow(ctx, poly, SHADOW_RGB, 0.46);
drawBlock(ctx, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT, LILAC_TOP, LILAC_FRONT);
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastPointLightPlanarProjection.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_HEIGHT = 220;
const ORBIT_RADIUS = 110;
const LIGHT_LERP = 0.2;
const BOX_HEIGHT = 70;
const BOX_RECT = [212, 140, 268, 178];
const CENTER_X = 240;
const CENTER_Y = 150;
const FLOOR_RGB = [243, 238, 250];
const SHADOW_RGB = [74, 56, 120];
const WARM_RGB = [255, 226, 150];
const LILAC_TOP = [196, 172, 255];
const LILAC_FRONT = [150, 120, 232];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = CENTER_X + ORBIT_RADIUS;
let ly = CENTER_Y;
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [CENTER_X + ORBIT_RADIUS * Math.cos(a), CENTER_Y + ORBIT_RADIUS * 0.72 * Math.sin(a)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { lx = tx; ly = ty; },
draw: function (): void {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 300, WARM_RGB, 0.95);
const poly = boxShadow(lx, ly, LIGHT_HEIGHT, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT);
fillShadow(ctx, poly, SHADOW_RGB, 0.46);
drawBlock(ctx, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT, LILAC_TOP, LILAC_FRONT);
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastPointLightPlanarProjection() {
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>
<p className={styles.sc__caption}>광원의 위치와 물체 높이로 꼭짓점마다 바닥 좌표를 계산해, 광원이 돌면 그림자가 반대쪽으로 돌아간다.</p>
</div>
</main>
);
}01 · 光が回ると床に投影される影 — 計測 累積面積 63.387% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [CENTER_X + ORBIT_RADIUS * Math.cos(a), CENTER_Y + ORBIT_RADIUS * 0.72 * Math.sin(a)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
02ポインタを光にする柱の列
光ひとつが LIGHT_LERP 0.12 の割合で目標点を追い、柱六本は種444で決めた高さ67・101・88・59・64・74に合わせて、それぞれ影を投影し直します。デモの間は仮想の経路で動き、キャンバスを押すとそこからポインタを追います。測定は累積面積35.0%・平均強度44.0・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="ポインターへ寄っていく光源一つが、柱ごとに影の向きと長さを計算し直す。画面を押すと自分で動かせる。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">ポインターへ寄っていく光源一つが、柱ごとに影の向きと長さを計算し直す。画面を押すと自分で動かせる。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_LERP = 0.12;
const LIGHT_HEIGHT = 200;
const PILLAR_COUNT = 6;
const PILLAR_WIDTH = 22;
const PILLAR_DEPTH = 14;
const FLOOR_TOP = 118;
const BASE_Y = 214;
const SEED = 444;
const WALL_RGB = [226, 214, 250];
const FLOOR_RGB = [250, 240, 226];
const SHADOW_RGB = [80, 58, 120];
const WARM_RGB = [255, 214, 140];
const PILLAR_TOPS = [[255, 214, 204], [190, 226, 255], [214, 196, 255], [180, 240, 216], [255, 232, 160], [255, 200, 224]];
const PILLAR_FRONTS = [[236, 160, 146], [120, 180, 238], [170, 144, 236], [120, 214, 178], [236, 194, 100], [236, 150, 190]];
function makeScene(ctx) {
let lx = 240;
let ly = 200;
const rnd = mulberry32(SEED);
const heights = [];
for (let i = 0; i < PILLAR_COUNT; i += 1) {
heights.push(58 + Math.floor(rnd() * 44));
}
function pillarX(i) { return 58 + i * (364 / (PILLAR_COUNT - 1)); }
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 190 * Math.sin(a), 212 + 70 * Math.sin(2 * a + 1)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx, ty) { lx = tx; ly = ty; },
draw: function () {
const fy = clamp(ly, FLOOR_TOP + 24, H - 12);
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, FLOOR_TOP);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.save();
ctx.beginPath();
ctx.rect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.clip();
glow(ctx, lx, fy, 280, WARM_RGB, 0.9);
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
const poly = boxShadow(lx, fy, LIGHT_HEIGHT, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i]);
fillShadow(ctx, poly, SHADOW_RGB, 0.44);
}
ctx.restore();
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
drawBlock(ctx, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i], PILLAR_TOPS[i], PILLAR_FRONTS[i]);
}
glow(ctx, lx, fy, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, fy, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastPointerLightPillarRow.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_LERP = 0.12;
const LIGHT_HEIGHT = 200;
const PILLAR_COUNT = 6;
const PILLAR_WIDTH = 22;
const PILLAR_DEPTH = 14;
const FLOOR_TOP = 118;
const BASE_Y = 214;
const SEED = 444;
const WALL_RGB = [226, 214, 250];
const FLOOR_RGB = [250, 240, 226];
const SHADOW_RGB = [80, 58, 120];
const WARM_RGB = [255, 214, 140];
const PILLAR_TOPS = [[255, 214, 204], [190, 226, 255], [214, 196, 255], [180, 240, 216], [255, 232, 160], [255, 200, 224]];
const PILLAR_FRONTS = [[236, 160, 146], [120, 180, 238], [170, 144, 236], [120, 214, 178], [236, 194, 100], [236, 150, 190]];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 240;
let ly = 200;
const rnd = mulberry32(SEED);
const heights: number[] = [];
for (let i = 0; i < PILLAR_COUNT; i += 1) { heights.push(58 + Math.floor(rnd() * 44)); }
function pillarX(i: number): number { return 58 + i * (364 / (PILLAR_COUNT - 1)); }
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 190 * Math.sin(a), 212 + 70 * Math.sin(2 * a + 1)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { lx = tx; ly = ty; },
draw: function (): void {
const fy = clamp(ly, FLOOR_TOP + 24, H - 12);
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, FLOOR_TOP);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.save();
ctx.beginPath();
ctx.rect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.clip();
glow(ctx, lx, fy, 280, WARM_RGB, 0.9);
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
const poly = boxShadow(lx, fy, LIGHT_HEIGHT, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i]);
fillShadow(ctx, poly, SHADOW_RGB, 0.44);
}
ctx.restore();
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
drawBlock(ctx, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i], PILLAR_TOPS[i], PILLAR_FRONTS[i]);
}
glow(ctx, lx, fy, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, fy, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastPointerLightPillarRow() {
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>
<p className={styles.sc__caption}>포인터 위치로 다가가는 광원 하나가 기둥 여럿의 그림자 방향과 길이를 각각 다시 계산한다. 화면을 누르면 직접 움직인다.</p>
</div>
</main>
);
}02 · ポインターを光源にした柱の列 — 計測 累積面積 34.99% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function makeScene(ctx) {
let lx = 240;
let ly = 200;
const rnd = mulberry32(SEED);
const heights = [];
for (let i = 0; i < PILLAR_COUNT; i += 1) {
heights.push(58 + Math.floor(rnd() * 44));
}
function pillarX(i) { return 58 + i * (364 / (PILLAR_COUNT - 1)); }
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
const poly = boxShadow(lx, fy, LIGHT_HEIGHT, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i]);
fillShadow(ctx, poly, SHADOW_RGB, 0.44);
}
03高さに応じてぼやける影
板を64本の帯に割って帯ごとに床から浮いた高さを求め、ぼかしを 2 + 高さ×BLUR_PER_HEIGHT、濃さを CONTACT_ALPHA÷(1 + 高さ×0.03)で決めて、帯ごとに縦のグラデーションを塗ります。床に触れた端の帯(高さ0.86)はぼかし2.17px・濃さ0.604、浮いた端の帯(高さ109.14)はぼかし23.83px・濃さ0.145で、一枚の板の中でくっきり具合が分かれます。測定は累積面積32.8%・平均強度44.9・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="片端だけ浮いた板で、床からの高さに比例して影のぼかしと濃さを帯ごとに計算し、接する所と離れた所の見え方を変える。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">片端だけ浮いた板で、床からの高さに比例して影のぼかしと濃さを帯ごとに計算し、接する所と離れた所の見え方を変える。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const BLUR_PER_HEIGHT = 0.2;
const CONTACT_ALPHA = 0.62;
const LIFT_MAX = 110;
const LIGHT_LERP = 0.16;
const PLANK_LEFT = 80;
const PLANK_LEN = 320;
const PLANK_THICK = 14;
const STRIPS = 64;
const BASE_Y = 168;
const WALL_RGB = [255, 244, 214];
const FLOOR_RGB = [236, 228, 250];
const SHADOW_RGB = [70, 54, 116];
const PLANK_TOP = [150, 206, 255];
const PLANK_EDGE = [96, 160, 226];
function makeScene(ctx) {
let px = 240;
let py = 160;
function lift() { return LIFT_MAX * clamp((py - 30) / 240, 0, 1); }
// 가로 위치 u (0 은 닿은 쪽, 1 은 뜬 쪽) 에서 판의 높이
function heightAt(u, total) { return total * u; }
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 170 * Math.sin(a * 2), 150 + 130 * Math.sin(a - 1.2)];
},
pos: function () { return [px, py]; },
update: function (tx, ty) {
px = lerp(px, tx, LIGHT_LERP);
py = lerp(py, ty, LIGHT_LERP);
},
snap: function (tx, ty) { px = tx; py = ty; },
draw: function () {
const total = lift();
const slide = (px - 240) / 240;
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, BASE_Y - 20);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, BASE_Y - 20, W, H - BASE_Y + 20);
// 그림자 띠 — 띠마다 높이로 번짐과 농도를 따로 계산한다
for (let i = 0; i < STRIPS; i += 1) {
const h0 = heightAt(i / STRIPS, total);
const h1 = heightAt((i + 1) / STRIPS, total);
const hm = (h0 + h1) / 2;
const xa = Math.round(PLANK_LEFT + (PLANK_LEN * i) / STRIPS + h0 * slide * 0.9);
const xb = Math.round(PLANK_LEFT + (PLANK_LEN * (i + 1)) / STRIPS + h1 * slide * 0.9);
const blur = 2 + hm * BLUR_PER_HEIGHT;
const alpha = CONTACT_ALPHA / (1 + hm * 0.03);
const yTop = BASE_Y - 2;
const yBot = BASE_Y + 16 + hm * 0.5;
const g = ctx.createLinearGradient(0, yTop - blur, 0, yBot + blur);
const span = yBot - yTop + 2 * blur;
g.addColorStop(0, rgba(SHADOW_RGB, 0));
g.addColorStop(clamp(blur * 1.2 / span, 0, 0.45), rgba(SHADOW_RGB, alpha));
g.addColorStop(clamp(1 - blur * 1.2 / span, 0.55, 1), rgba(SHADOW_RGB, alpha * 0.85));
g.addColorStop(1, rgba(SHADOW_RGB, 0));
ctx.fillStyle = g;
ctx.fillRect(xa, yTop - blur, xb - xa, span);
}
// 판 — 왼쪽 끝은 바닥에 닿고 오른쪽 끝만 들린다
ctx.beginPath();
for (let i = 0; i <= STRIPS; i += 1) {
const hh = heightAt(i / STRIPS, total);
const x = PLANK_LEFT + (PLANK_LEN * i) / STRIPS;
const y = BASE_Y - hh * 0.7 - PLANK_THICK;
if (i === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
for (let i = STRIPS; i >= 0; i -= 1) {
const hh = heightAt(i / STRIPS, total);
ctx.lineTo(PLANK_LEFT + (PLANK_LEN * i) / STRIPS, BASE_Y - hh * 0.7);
}
ctx.closePath();
ctx.fillStyle = rgba(PLANK_TOP, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PLANK_EDGE, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(PLANK_LEFT, BASE_Y - PLANK_THICK / 2, 5, 0, PI2);
ctx.fillStyle = rgba(PLANK_EDGE, 1);
ctx.fill();
// 광원 표시 — 가로 위치가 그림자가 쏠리는 쪽을 정한다
glow(ctx, px, 30, 46, [255, 220, 120], 0.9);
ctx.beginPath();
ctx.arc(px, 30, 8, 0, PI2);
ctx.fillStyle = 'rgba(255,246,208,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastHeightPenumbraBlur.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const BLUR_PER_HEIGHT = 0.2;
const CONTACT_ALPHA = 0.62;
const LIFT_MAX = 110;
const LIGHT_LERP = 0.16;
const PLANK_LEFT = 80;
const PLANK_LEN = 320;
const PLANK_THICK = 14;
const STRIPS = 64;
const BASE_Y = 168;
const WALL_RGB = [255, 244, 214];
const FLOOR_RGB = [236, 228, 250];
const SHADOW_RGB = [70, 54, 116];
const PLANK_TOP = [150, 206, 255];
const PLANK_EDGE = [96, 160, 226];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let px = 240;
let py = 160;
function lift(): number { return LIFT_MAX * clamp((py - 30) / 240, 0, 1); }
// 가로 위치 u (0 은 닿은 쪽, 1 은 뜬 쪽) 에서 판의 높이
function heightAt(u: number, total: number): number { return total * u; }
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 170 * Math.sin(a * 2), 150 + 130 * Math.sin(a - 1.2)];
},
pos: function (): Pt { return [px, py]; },
update: function (tx: number, ty: number): void {
px = lerp(px, tx, LIGHT_LERP);
py = lerp(py, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { px = tx; py = ty; },
draw: function (): void {
const total = lift();
const slide = (px - 240) / 240;
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, BASE_Y - 20);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, BASE_Y - 20, W, H - BASE_Y + 20);
// 그림자 띠 — 띠마다 높이로 번짐과 농도를 따로 계산한다
for (let i = 0; i < STRIPS; i += 1) {
const h0 = heightAt(i / STRIPS, total);
const h1 = heightAt((i + 1) / STRIPS, total);
const hm = (h0 + h1) / 2;
const xa = Math.round(PLANK_LEFT + (PLANK_LEN * i) / STRIPS + h0 * slide * 0.9);
const xb = Math.round(PLANK_LEFT + (PLANK_LEN * (i + 1)) / STRIPS + h1 * slide * 0.9);
const blur = 2 + hm * BLUR_PER_HEIGHT;
const alpha = CONTACT_ALPHA / (1 + hm * 0.03);
const yTop = BASE_Y - 2;
const yBot = BASE_Y + 16 + hm * 0.5;
const g = ctx.createLinearGradient(0, yTop - blur, 0, yBot + blur);
const span = yBot - yTop + 2 * blur;
g.addColorStop(0, rgba(SHADOW_RGB, 0));
g.addColorStop(clamp(blur * 1.2 / span, 0, 0.45), rgba(SHADOW_RGB, alpha));
g.addColorStop(clamp(1 - blur * 1.2 / span, 0.55, 1), rgba(SHADOW_RGB, alpha * 0.85));
g.addColorStop(1, rgba(SHADOW_RGB, 0));
ctx.fillStyle = g;
ctx.fillRect(xa, yTop - blur, xb - xa, span);
}
// 판 — 왼쪽 끝은 바닥에 닿고 오른쪽 끝만 들린다
ctx.beginPath();
for (let i = 0; i <= STRIPS; i += 1) {
const hh = heightAt(i / STRIPS, total);
const x = PLANK_LEFT + (PLANK_LEN * i) / STRIPS;
const y = BASE_Y - hh * 0.7 - PLANK_THICK;
if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
for (let i = STRIPS; i >= 0; i -= 1) {
const hh = heightAt(i / STRIPS, total);
ctx.lineTo(PLANK_LEFT + (PLANK_LEN * i) / STRIPS, BASE_Y - hh * 0.7);
}
ctx.closePath();
ctx.fillStyle = rgba(PLANK_TOP, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PLANK_EDGE, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(PLANK_LEFT, BASE_Y - PLANK_THICK / 2, 5, 0, PI2);
ctx.fillStyle = rgba(PLANK_EDGE, 1);
ctx.fill();
// 광원 표시 — 가로 위치가 그림자가 쏠리는 쪽을 정한다
glow(ctx, px, 30, 46, [255, 220, 120], 0.9);
ctx.beginPath();
ctx.arc(px, 30, 8, 0, PI2);
ctx.fillStyle = 'rgba(255,246,208,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastHeightPenumbraBlur() {
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>
<p className={styles.sc__caption}>한쪽 끝만 뜬 판에서 바닥 높이에 비례해 그림자의 흐림과 농도를 띠마다 계산해, 맞닿은 곳과 떨어진 곳이 다르게 보인다.</p>
</div>
</main>
);
}03 · 高さに応じてぼやける影 — 計測 累積面積 32.814% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let i = 0; i < STRIPS; i += 1) {
const h0 = heightAt(i / STRIPS, total);
const h1 = heightAt((i + 1) / STRIPS, total);
const hm = (h0 + h1) / 2;
const xa = Math.round(PLANK_LEFT + (PLANK_LEN * i) / STRIPS + h0 * slide * 0.9);
const xb = Math.round(PLANK_LEFT + (PLANK_LEN * (i + 1)) / STRIPS + h1 * slide * 0.9);
const blur = 2 + hm * BLUR_PER_HEIGHT;
const alpha = CONTACT_ALPHA / (1 + hm * 0.03);
const yTop = BASE_Y - 2;
const yBot = BASE_Y + 16 + hm * 0.5;
const g = ctx.createLinearGradient(0, yTop - blur, 0, yBot + blur);
const span = yBot - yTop + 2 * blur;
g.addColorStop(0, rgba(SHADOW_RGB, 0));
g.addColorStop(clamp(blur * 1.2 / span, 0, 0.45), rgba(SHADOW_RGB, alpha));
g.addColorStop(clamp(1 - blur * 1.2 / span, 0.55, 1), rgba(SHADOW_RGB, alpha * 0.85));
g.addColorStop(1, rgba(SHADOW_RGB, 0));
ctx.fillStyle = g;
ctx.fillRect(xa, yTop - blur, xb - xa, span);
}
04仮想の太陽が弧を描く日時計
太陽の進み具合ひとつから高度を 14° + 56°×sin(π×進み)で決め、影の長さは GNOMON_HEIGHT÷tan(高度)を SHADOW_LEN_MAX の140で打ち切って求めます。向きは太陽の角度の反対側なので、太陽が160°の弧を渡る間、影は長くなって短くなりながら逆向きに回ります。測定は累積面積28.5%・平均強度37.1・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="太陽の高度角一つから影の長さを高さ÷タンジェントで求め、決めた弧に沿って向きと長さを変える。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">太陽の高度角一つから影の長さを高さ÷タンジェントで求め、決めた弧に沿って向きと長さを変える。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const SUN_ARC_DEG = 160;
const SHADOW_LEN_MAX = 140;
const GNOMON_HEIGHT = 60;
const DIAL_RADIUS = 112;
const SUN_RING_RADIUS = 134;
const ELEV_LOW_DEG = 14;
const ELEV_SPAN_DEG = 56;
const LIGHT_LERP = 0.2;
const DIAL_X = 240;
const DIAL_Y = 160;
const BG_RGB = [255, 244, 222];
const PLATE_RGB = [214, 232, 255];
const INK_RGB = [74, 56, 120];
const SUN_RGB = [255, 214, 120];
function makeScene(ctx) {
let ux = 0.04;
// 해의 진행 u (0 은 해 뜬 직후, 1 은 지기 직전) 를 화면 각도로 바꾼다
function sunAngle(u) {
const mid = 270;
return (mid - SUN_ARC_DEG / 2 + u * SUN_ARC_DEG) * Math.PI / 180;
}
function elevation(u) { return (ELEV_LOW_DEG + ELEV_SPAN_DEG * Math.sin(Math.PI * u)) * Math.PI / 180; }
const scene = {
path: function (t) {
const c = (t / PERIOD_MS) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * (0.04 + 0.92 * smooth(tri)), DIAL_Y];
},
pos: function () { return [ux * W, DIAL_Y]; },
update: function (tx) { ux = lerp(ux, clamp(tx / W, 0, 1), LIGHT_LERP); },
snap: function (tx) { ux = clamp(tx / W, 0, 1); },
draw: function () {
const phi = sunAngle(ux);
const e = elevation(ux);
const len = Math.min(SHADOW_LEN_MAX, GNOMON_HEIGHT / Math.tan(e));
ctx.fillStyle = rgba(BG_RGB, 1);
ctx.fillRect(0, 0, W, H);
// 해가 지나는 호
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, SUN_RING_RADIUS, sunAngle(0), sunAngle(1));
ctx.lineWidth = 2;
ctx.setLineDash([4, 6]);
ctx.strokeStyle = 'rgba(214,170,70,0.75)';
ctx.stroke();
ctx.setLineDash([]);
// 해시계 판 — 해가 높을수록 밝다
ctx.save();
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.fillStyle = rgba(PLATE_RGB, 1);
ctx.fill();
ctx.clip();
const sx = DIAL_X + Math.cos(phi) * SUN_RING_RADIUS;
const sy = DIAL_Y + Math.sin(phi) * SUN_RING_RADIUS;
glow(ctx, sx, sy, 230, SUN_RGB, 0.55 + 0.4 * Math.sin(Math.PI * ux));
ctx.restore();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(150,170,230,0.9)';
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.stroke();
// 눈금 — 해가 8 등분 지점에 있을 때 그림자 끝이 닿는 방향
for (let k = 0; k <= 8; k += 1) {
const a = sunAngle(k / 8) + Math.PI;
ctx.beginPath();
ctx.moveTo(DIAL_X + Math.cos(a) * 92, DIAL_Y + Math.sin(a) * 92);
ctx.lineTo(DIAL_X + Math.cos(a) * 106, DIAL_Y + Math.sin(a) * 106);
ctx.lineWidth = k % 4 === 0 ? 4 : 2;
ctx.strokeStyle = 'rgba(96,110,190,0.85)';
ctx.stroke();
}
// 그림자 — 해 반대쪽으로 길이 = 막대 높이 / tan(고도)
const da = phi + Math.PI;
const tipX = DIAL_X + Math.cos(da) * len;
const tipY = DIAL_Y + Math.sin(da) * len;
const nx = -Math.sin(da) * 6;
const ny = Math.cos(da) * 6;
ctx.beginPath();
ctx.moveTo(DIAL_X + nx, DIAL_Y + ny);
ctx.lineTo(tipX, tipY);
ctx.lineTo(DIAL_X - nx, DIAL_Y - ny);
ctx.closePath();
ctx.fillStyle = rgba(INK_RGB, 0.52);
ctx.fill();
ctx.beginPath();
ctx.arc(tipX, tipY, 4, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 0.8);
ctx.fill();
// 막대 윗부분과 해
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y - 4, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,196,176,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,120,100,0.95)';
ctx.stroke();
glow(ctx, sx, sy, 34, SUN_RGB, 0.95);
ctx.beginPath();
ctx.arc(sx, sy, 10, 0, PI2);
ctx.fillStyle = 'rgba(255,240,186,1)';
ctx.fill();
ctx.strokeStyle = 'rgba(224,170,60,0.95)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastSunArcSundial.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const SUN_ARC_DEG = 160;
const SHADOW_LEN_MAX = 140;
const GNOMON_HEIGHT = 60;
const DIAL_RADIUS = 112;
const SUN_RING_RADIUS = 134;
const ELEV_LOW_DEG = 14;
const ELEV_SPAN_DEG = 56;
const LIGHT_LERP = 0.2;
const DIAL_X = 240;
const DIAL_Y = 160;
const BG_RGB = [255, 244, 222];
const PLATE_RGB = [214, 232, 255];
const INK_RGB = [74, 56, 120];
const SUN_RGB = [255, 214, 120];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let ux = 0.04;
// 해의 진행 u (0 은 해 뜬 직후, 1 은 지기 직전) 를 화면 각도로 바꾼다
function sunAngle(u: number): number {
const mid = 270;
return (mid - SUN_ARC_DEG / 2 + u * SUN_ARC_DEG) * Math.PI / 180;
}
function elevation(u: number): number { return (ELEV_LOW_DEG + ELEV_SPAN_DEG * Math.sin(Math.PI * u)) * Math.PI / 180; }
const scene: Scene = {
path: function (t: number): Pt {
const c = (t / PERIOD_MS) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * (0.04 + 0.92 * smooth(tri)), DIAL_Y];
},
pos: function (): Pt { return [ux * W, DIAL_Y]; },
update: function (tx: number): void { ux = lerp(ux, clamp(tx / W, 0, 1), LIGHT_LERP); },
snap: function (tx: number): void { ux = clamp(tx / W, 0, 1); },
draw: function (): void {
const phi = sunAngle(ux);
const e = elevation(ux);
const len = Math.min(SHADOW_LEN_MAX, GNOMON_HEIGHT / Math.tan(e));
ctx.fillStyle = rgba(BG_RGB, 1);
ctx.fillRect(0, 0, W, H);
// 해가 지나는 호
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, SUN_RING_RADIUS, sunAngle(0), sunAngle(1));
ctx.lineWidth = 2;
ctx.setLineDash([4, 6]);
ctx.strokeStyle = 'rgba(214,170,70,0.75)';
ctx.stroke();
ctx.setLineDash([]);
// 해시계 판 — 해가 높을수록 밝다
ctx.save();
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.fillStyle = rgba(PLATE_RGB, 1);
ctx.fill();
ctx.clip();
const sx = DIAL_X + Math.cos(phi) * SUN_RING_RADIUS;
const sy = DIAL_Y + Math.sin(phi) * SUN_RING_RADIUS;
glow(ctx, sx, sy, 230, SUN_RGB, 0.55 + 0.4 * Math.sin(Math.PI * ux));
ctx.restore();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(150,170,230,0.9)';
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.stroke();
// 눈금 — 해가 8 등분 지점에 있을 때 그림자 끝이 닿는 방향
for (let k = 0; k <= 8; k += 1) {
const a = sunAngle(k / 8) + Math.PI;
ctx.beginPath();
ctx.moveTo(DIAL_X + Math.cos(a) * 92, DIAL_Y + Math.sin(a) * 92);
ctx.lineTo(DIAL_X + Math.cos(a) * 106, DIAL_Y + Math.sin(a) * 106);
ctx.lineWidth = k % 4 === 0 ? 4 : 2;
ctx.strokeStyle = 'rgba(96,110,190,0.85)';
ctx.stroke();
}
// 그림자 — 해 반대쪽으로 길이 = 막대 높이 / tan(고도)
const da = phi + Math.PI;
const tipX = DIAL_X + Math.cos(da) * len;
const tipY = DIAL_Y + Math.sin(da) * len;
const nx = -Math.sin(da) * 6;
const ny = Math.cos(da) * 6;
ctx.beginPath();
ctx.moveTo(DIAL_X + nx, DIAL_Y + ny);
ctx.lineTo(tipX, tipY);
ctx.lineTo(DIAL_X - nx, DIAL_Y - ny);
ctx.closePath();
ctx.fillStyle = rgba(INK_RGB, 0.52);
ctx.fill();
ctx.beginPath();
ctx.arc(tipX, tipY, 4, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 0.8);
ctx.fill();
// 막대 윗부분과 해
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y - 4, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,196,176,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,120,100,0.95)';
ctx.stroke();
glow(ctx, sx, sy, 34, SUN_RGB, 0.95);
ctx.beginPath();
ctx.arc(sx, sy, 10, 0, PI2);
ctx.fillStyle = 'rgba(255,240,186,1)';
ctx.fill();
ctx.strokeStyle = 'rgba(224,170,60,0.95)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastSunArcSundial() {
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>
<p className={styles.sc__caption}>해의 고도각 하나에서 그림자 길이를 높이 나누기 탄젠트로 구하고, 정해진 호를 따라 방향과 길이를 바꾼다.</p>
</div>
</main>
);
}04 · 仮想の太陽が弧を描く日時計 — 計測 累積面積 28.476% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 太陽の進み u(0 は日の出の直後、1 は日没の直前)を画面の角度に変える
function sunAngle(u) {
const mid = 270;
return (mid - SUN_ARC_DEG / 2 + u * SUN_ARC_DEG) * Math.PI / 180;
}
function elevation(u) { return (ELEV_LOW_DEG + ELEV_SPAN_DEG * Math.sin(Math.PI * u)) * Math.PI / 180; }
const phi = sunAngle(ux);
const e = elevation(ux);
const len = Math.min(SHADOW_LEN_MAX, GNOMON_HEIGHT / Math.tan(e));
05壁にさえぎられる光の領域
visibilityPolygon が等間隔の光線 RAY_COUNT 180本と、壁の線分ごとに端点へ向けて −ANGLE_EPS・0・+ANGLE_EPS とずらして放つ三本ずつを集めて角度順に並べ、castRay が最も近い交点を探して、その点をつないで光の多角形にします。この多角形の内側だけを clip して明るく塗るので、壁の裏は暗いまま残ります。測定は累積面積65.6%・平均強度74.3・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="光源から放った光線が壁の線分と最初に交わる点をつなぎ、光の届く多角形だけを明るく塗る。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">光源から放った光線が壁の線分と最初に交わる点をつなぎ、光の届く多角形だけを明るく塗る。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const RAY_COUNT = 180;
const ANGLE_EPS = 0.0004;
const LIGHT_RADIUS = 330;
const LIGHT_LERP = 0.2;
const WALL_MARGIN = 4;
const WALL_RECTS = [[60, 64, 60, 56], [188, 22, 34, 96], [300, 84, 72, 40], [104, 206, 92, 32], [318, 200, 32, 72], [214, 150, 42, 42]];
const FAR_RGB = [160, 148, 196];
const LIT_NEAR = [255, 246, 214];
const LIT_FAR = [255, 206, 160];
const WALL_TOP = [226, 214, 255];
const WALL_SIDE = [170, 150, 232];
function buildSegments() {
const out = [[0, 0, W, 0], [W, 0, W, H], [W, H, 0, H], [0, H, 0, 0]];
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
out.push([r[0], r[1], r[0] + r[2], r[1]]);
out.push([r[0] + r[2], r[1], r[0] + r[2], r[1] + r[3]]);
out.push([r[0] + r[2], r[1] + r[3], r[0], r[1] + r[3]]);
out.push([r[0], r[1] + r[3], r[0], r[1]]);
}
return out;
}
const WALL_SEGMENTS = buildSegments();
// 광선 하나가 벽 선분 가운데 가장 가까이에서 부딪히는 거리
function castRay(ox, oy, dx, dy) {
let best = 4000;
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const ex = s[2] - s[0];
const ey = s[3] - s[1];
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) {
continue;
}
const t = ((s[0] - ox) * ey - (s[1] - oy) * ex) / den;
const u = ((s[0] - ox) * dy - (s[1] - oy) * dx) / den;
if (t > 0 && u >= 0 && u <= 1 && t < best) {
best = t;
}
}
return best;
}
// 광원이 벽 안에 들어가면 가장 가까운 바깥 변으로 밀어낸다
function pushOut(p) {
let x = clamp(p[0], WALL_MARGIN, W - WALL_MARGIN);
let y = clamp(p[1], WALL_MARGIN, H - WALL_MARGIN);
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
const x0 = r[0] - WALL_MARGIN;
const y0 = r[1] - WALL_MARGIN;
const x1 = r[0] + r[2] + WALL_MARGIN;
const y1 = r[1] + r[3] + WALL_MARGIN;
if (x > x0 && x < x1 && y > y0 && y < y1) {
const dl = x - x0;
const dr = x1 - x;
const dt = y - y0;
const db = y1 - y;
const m = Math.min(dl, dr, dt, db);
if (m === dl) {
x = x0;
}
else if (m === dr) {
x = x1;
}
else if (m === dt) {
y = y0;
}
else {
y = y1;
}
}
}
return [x, y];
}
function visibilityPolygon(ox, oy) {
const angles = [];
for (let i = 0; i < RAY_COUNT; i += 1) {
angles.push(PI2 * i / RAY_COUNT);
}
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const a = Math.atan2(s[1] - oy, s[0] - ox);
angles.push(a - ANGLE_EPS, a, a + ANGLE_EPS);
}
angles.sort(function (p, q) { return p - q; });
const out = [];
for (let i = 0; i < angles.length; i += 1) {
const dx = Math.cos(angles[i]);
const dy = Math.sin(angles[i]);
const d = castRay(ox, oy, dx, dy);
out.push([ox + dx * d, oy + dy * d]);
}
return out;
}
function makeScene(ctx) {
let lx = 240;
let ly = 120;
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return pushOut([240 + 200 * Math.sin(a), 150 + 118 * Math.sin(a * 2 + 0.6)]);
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
const p = pushOut([lerp(lx, tx, LIGHT_LERP), lerp(ly, ty, LIGHT_LERP)]);
lx = p[0];
ly = p[1];
},
snap: function (tx, ty) {
const p = pushOut([tx, ty]);
lx = p[0];
ly = p[1];
},
draw: function () {
ctx.fillStyle = rgba(FAR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const poly = visibilityPolygon(lx, ly);
ctx.save();
polyPath(ctx, poly);
ctx.clip();
const g = ctx.createRadialGradient(lx, ly, 0, lx, ly, LIGHT_RADIUS);
g.addColorStop(0, rgba(LIT_NEAR, 1));
g.addColorStop(1, rgba(LIT_FAR, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.restore();
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
ctx.fillStyle = rgba(WALL_SIDE, 1);
ctx.fillRect(r[0], r[1], r[2], r[3]);
ctx.fillStyle = rgba(WALL_TOP, 1);
ctx.fillRect(r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 8);
}
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,250,224,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastWallVisibilityPolygon.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
type Seg = [number, number, number, number];
const RAY_COUNT = 180;
const ANGLE_EPS = 0.0004;
const LIGHT_RADIUS = 330;
const LIGHT_LERP = 0.2;
const WALL_MARGIN = 4;
const WALL_RECTS = [[60, 64, 60, 56], [188, 22, 34, 96], [300, 84, 72, 40], [104, 206, 92, 32], [318, 200, 32, 72], [214, 150, 42, 42]];
const FAR_RGB = [160, 148, 196];
const LIT_NEAR = [255, 246, 214];
const LIT_FAR = [255, 206, 160];
const WALL_TOP = [226, 214, 255];
const WALL_SIDE = [170, 150, 232];
function buildSegments(): Seg[] {
const out: Seg[] = [[0, 0, W, 0], [W, 0, W, H], [W, H, 0, H], [0, H, 0, 0]];
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
out.push([r[0], r[1], r[0] + r[2], r[1]]);
out.push([r[0] + r[2], r[1], r[0] + r[2], r[1] + r[3]]);
out.push([r[0] + r[2], r[1] + r[3], r[0], r[1] + r[3]]);
out.push([r[0], r[1] + r[3], r[0], r[1]]);
}
return out;
}
const WALL_SEGMENTS: Seg[] = buildSegments();
// 광선 하나가 벽 선분 가운데 가장 가까이에서 부딪히는 거리
function castRay(ox: number, oy: number, dx: number, dy: number): number {
let best = 4000;
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const ex = s[2] - s[0];
const ey = s[3] - s[1];
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) { continue; }
const t = ((s[0] - ox) * ey - (s[1] - oy) * ex) / den;
const u = ((s[0] - ox) * dy - (s[1] - oy) * dx) / den;
if (t > 0 && u >= 0 && u <= 1 && t < best) { best = t; }
}
return best;
}
// 광원이 벽 안에 들어가면 가장 가까운 바깥 변으로 밀어낸다
function pushOut(p: Pt): Pt {
let x = clamp(p[0], WALL_MARGIN, W - WALL_MARGIN);
let y = clamp(p[1], WALL_MARGIN, H - WALL_MARGIN);
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
const x0 = r[0] - WALL_MARGIN;
const y0 = r[1] - WALL_MARGIN;
const x1 = r[0] + r[2] + WALL_MARGIN;
const y1 = r[1] + r[3] + WALL_MARGIN;
if (x > x0 && x < x1 && y > y0 && y < y1) {
const dl = x - x0;
const dr = x1 - x;
const dt = y - y0;
const db = y1 - y;
const m = Math.min(dl, dr, dt, db);
if (m === dl) { x = x0; } else if (m === dr) { x = x1; } else if (m === dt) { y = y0; } else { y = y1; }
}
}
return [x, y];
}
function visibilityPolygon(ox: number, oy: number): Pt[] {
const angles: number[] = [];
for (let i = 0; i < RAY_COUNT; i += 1) { angles.push(PI2 * i / RAY_COUNT); }
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const a = Math.atan2(s[1] - oy, s[0] - ox);
angles.push(a - ANGLE_EPS, a, a + ANGLE_EPS);
}
angles.sort(function (p: number, q: number): number { return p - q; });
const out: Pt[] = [];
for (let i = 0; i < angles.length; i += 1) {
const dx = Math.cos(angles[i]);
const dy = Math.sin(angles[i]);
const d = castRay(ox, oy, dx, dy);
out.push([ox + dx * d, oy + dy * d]);
}
return out;
}
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 240;
let ly = 120;
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return pushOut([240 + 200 * Math.sin(a), 150 + 118 * Math.sin(a * 2 + 0.6)]);
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
const p = pushOut([lerp(lx, tx, LIGHT_LERP), lerp(ly, ty, LIGHT_LERP)]);
lx = p[0];
ly = p[1];
},
snap: function (tx: number, ty: number): void {
const p = pushOut([tx, ty]);
lx = p[0];
ly = p[1];
},
draw: function (): void {
ctx.fillStyle = rgba(FAR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const poly = visibilityPolygon(lx, ly);
ctx.save();
polyPath(ctx, poly);
ctx.clip();
const g = ctx.createRadialGradient(lx, ly, 0, lx, ly, LIGHT_RADIUS);
g.addColorStop(0, rgba(LIT_NEAR, 1));
g.addColorStop(1, rgba(LIT_FAR, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.restore();
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
ctx.fillStyle = rgba(WALL_SIDE, 1);
ctx.fillRect(r[0], r[1], r[2], r[3]);
ctx.fillStyle = rgba(WALL_TOP, 1);
ctx.fillRect(r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 8);
}
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,250,224,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastWallVisibilityPolygon() {
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>
<p className={styles.sc__caption}>광원에서 쏜 광선이 벽 선분과 처음 만나는 점을 이어, 빛이 닿는 다각형만 밝게 칠한다.</p>
</div>
</main>
);
}05 · 壁に遮られる光の領域 — 計測 累積面積 65.603% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function castRay(ox, oy, dx, dy) {
let best = 4000;
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const ex = s[2] - s[0];
const ey = s[3] - s[1];
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) {
continue;
}
const t = ((s[0] - ox) * ey - (s[1] - oy) * ex) / den;
const u = ((s[0] - ox) * dy - (s[1] - oy) * dx) / den;
if (t > 0 && u >= 0 && u <= 1 && t < best) {
best = t;
}
}
return best;
}
function visibilityPolygon(ox, oy) {
const angles = [];
for (let i = 0; i < RAY_COUNT; i += 1) {
angles.push(PI2 * i / RAY_COUNT);
}
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const a = Math.atan2(s[1] - oy, s[0] - ox);
angles.push(a - ANGLE_EPS, a, a + ANGLE_EPS);
}
angles.sort(function (p, q) { return p - q; });
const out = [];
for (let i = 0; i < angles.length; i += 1) {
const dx = Math.cos(angles[i]);
const dy = Math.sin(angles[i]);
const d = castRay(ox, oy, dx, dy);
out.push([ox + dx * d, oy + dy * d]);
}
return out;
}
06段の高さから計算するカードの影
shadowOf が段の番号ひとつから、オフセット level × ELEV_STEP × (LIGHT_HEIGHT÷180)、ぼかし 2 + level × ELEV_STEP × 0.9、濃さ min(0.12 + 0.045×level, 0.6) を取り出し、キャンバスの shadowBlur で二重に塗ります。段1はオフセット3.33・ぼかし5.6・濃さ0.165、段5は16.67・20.0・0.345で、この三つの値はそのままCSSの box-shadow のオフセット・ぼかし・アルファに移せます。測定は累積面積41.6%・平均強度28.5・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="カードの層番号一つからオフセット・ぼかし・濃さを式で出してカードごとに違う影を塗り、その値はそのままbox-shadowに移せる。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">カードの層番号一つからオフセット・ぼかし・濃さを式で出してカードごとに違う影を塗り、その値はそのままbox-shadowに移せる。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const ELEV_STEP = 4;
const SHADOW_ALPHA_BASE = 0.12;
const CARD_COUNT = 5;
const CARD_W = 68;
const CARD_H = 124;
const CARD_Y = 98;
const HOVER_BONUS = 3;
const LIGHT_LERP = 0.16;
const LIGHT_HEIGHT = 150;
const FLOOR_RGB = [244, 238, 252];
const INK_RGB = [70, 50, 116];
const CARD_RGBS = [[255, 226, 214], [255, 240, 196], [214, 244, 226], [206, 232, 255], [232, 218, 255]];
// 층 번호 하나에서 그림자 값 하나를 계산한다 — 오프셋 · 번짐 · 농도
function shadowOf(level, dx, dy) {
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const reach = level * ELEV_STEP * (LIGHT_HEIGHT / 180);
return {
ox: (dx / dist) * reach,
oy: (dy / dist) * reach + level * 0.5,
blur: 2 + level * ELEV_STEP * 0.9,
alpha: Math.min(SHADOW_ALPHA_BASE + level * 0.045, 0.6)
};
}
function makeScene(ctx) {
let lx = 60;
let ly = 40;
const bonus = [];
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus.push(0);
}
function cardX(i) { return 62 + i * 88; }
function nearest() {
let best = 0;
for (let i = 1; i < CARD_COUNT; i += 1) {
if (Math.abs(cardX(i) + CARD_W / 2 - lx) < Math.abs(cardX(best) + CARD_W / 2 - lx)) {
best = i;
}
}
return best;
}
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 210 * Math.sin(a), 52 + 30 * Math.sin(a * 2)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus[i] = lerp(bonus[i], i === n ? HOVER_BONUS : 0, 0.14);
}
},
snap: function (tx, ty) {
lx = tx;
ly = ty;
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus[i] = i === n ? HOVER_BONUS : 0;
}
},
draw: function () {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 260, [255, 226, 160], 0.85);
for (let i = 0; i < CARD_COUNT; i += 1) {
const level = i + 1 + bonus[i];
const x = cardX(i);
const y = CARD_Y - bonus[i] * 3;
const s = shadowOf(level, x + CARD_W / 2 - lx, y + CARD_H / 2 - ly);
// 큰 번짐의 주변광 층과 짧은 주 그림자 층을 겹친다
ctx.shadowColor = rgba(INK_RGB, s.alpha);
ctx.shadowBlur = s.blur;
ctx.shadowOffsetX = s.ox;
ctx.shadowOffsetY = s.oy;
ctx.fillStyle = rgba(CARD_RGBS[i], 1);
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = rgba(INK_RGB, s.alpha * 0.7);
ctx.shadowBlur = s.blur * 0.4;
ctx.shadowOffsetX = s.ox * 0.35;
ctx.shadowOffsetY = s.oy * 0.35;
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = 'rgba(0,0,0,0)';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = rgba(INK_RGB, 0.72);
ctx.font = '600 14px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('E' + String(i + 1), x + CARD_W / 2, y + 26);
ctx.font = '500 10px ' + FONT;
ctx.fillText(String(Math.round(s.blur)) + 'px', x + CARD_W / 2, y + 44);
}
glow(ctx, lx, ly, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastElevationTokenCardStack.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const ELEV_STEP = 4;
const SHADOW_ALPHA_BASE = 0.12;
const CARD_COUNT = 5;
const CARD_W = 68;
const CARD_H = 124;
const CARD_Y = 98;
const HOVER_BONUS = 3;
const LIGHT_LERP = 0.16;
const LIGHT_HEIGHT = 150;
const FLOOR_RGB = [244, 238, 252];
const INK_RGB = [70, 50, 116];
const CARD_RGBS = [[255, 226, 214], [255, 240, 196], [214, 244, 226], [206, 232, 255], [232, 218, 255]];
// 층 번호 하나에서 그림자 값 하나를 계산한다 — 오프셋 · 번짐 · 농도
function shadowOf(level: number, dx: number, dy: number): { ox: number; oy: number; blur: number; alpha: number } {
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const reach = level * ELEV_STEP * (LIGHT_HEIGHT / 180);
return {
ox: (dx / dist) * reach,
oy: (dy / dist) * reach + level * 0.5,
blur: 2 + level * ELEV_STEP * 0.9,
alpha: Math.min(SHADOW_ALPHA_BASE + level * 0.045, 0.6)
};
}
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 60;
let ly = 40;
const bonus: number[] = [];
for (let i = 0; i < CARD_COUNT; i += 1) { bonus.push(0); }
function cardX(i: number): number { return 62 + i * 88; }
function nearest(): number {
let best = 0;
for (let i = 1; i < CARD_COUNT; i += 1) {
if (Math.abs(cardX(i) + CARD_W / 2 - lx) < Math.abs(cardX(best) + CARD_W / 2 - lx)) { best = i; }
}
return best;
}
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 210 * Math.sin(a), 52 + 30 * Math.sin(a * 2)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) { bonus[i] = lerp(bonus[i], i === n ? HOVER_BONUS : 0, 0.14); }
},
snap: function (tx: number, ty: number): void {
lx = tx;
ly = ty;
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) { bonus[i] = i === n ? HOVER_BONUS : 0; }
},
draw: function (): void {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 260, [255, 226, 160], 0.85);
for (let i = 0; i < CARD_COUNT; i += 1) {
const level = i + 1 + bonus[i];
const x = cardX(i);
const y = CARD_Y - bonus[i] * 3;
const s = shadowOf(level, x + CARD_W / 2 - lx, y + CARD_H / 2 - ly);
// 큰 번짐의 주변광 층과 짧은 주 그림자 층을 겹친다
ctx.shadowColor = rgba(INK_RGB, s.alpha);
ctx.shadowBlur = s.blur;
ctx.shadowOffsetX = s.ox;
ctx.shadowOffsetY = s.oy;
ctx.fillStyle = rgba(CARD_RGBS[i], 1);
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = rgba(INK_RGB, s.alpha * 0.7);
ctx.shadowBlur = s.blur * 0.4;
ctx.shadowOffsetX = s.ox * 0.35;
ctx.shadowOffsetY = s.oy * 0.35;
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = 'rgba(0,0,0,0)';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = rgba(INK_RGB, 0.72);
ctx.font = '600 14px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('E' + String(i + 1), x + CARD_W / 2, y + 26);
ctx.font = '500 10px ' + FONT;
ctx.fillText(String(Math.round(s.blur)) + 'px', x + CARD_W / 2, y + 44);
}
glow(ctx, lx, ly, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastElevationTokenCardStack() {
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="카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다.</p>
</div>
</main>
);
}06 · 層の高さから計算されるカードの影の値 — 計測 累積面積 41.603% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function shadowOf(level, dx, dy) {
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const reach = level * ELEV_STEP * (LIGHT_HEIGHT / 180);
return {
ox: (dx / dist) * reach,
oy: (dy / dist) * reach + level * 0.5,
blur: 2 + level * ELEV_STEP * 0.9,
alpha: Math.min(SHADOW_ALPHA_BASE + level * 0.045, 0.6)
};
}
const s = shadowOf(level, x + CARD_W / 2 - lx, y + CARD_H / 2 - ly);
// 大きくぼかした環境光の層と、短い主の影の層を重ねる
ctx.shadowColor = rgba(INK_RGB, s.alpha);
ctx.shadowBlur = s.blur;
ctx.shadowOffsetX = s.ox;
ctx.shadowOffsetY = s.oy;
ctx.fillStyle = rgba(CARD_RGBS[i], 1);
ctx.fillRect(x, y, CARD_W, CARD_H);
07物が降りるほど濃くなる接触の影
contactAlpha は高さ h を HEIGHT_MAX 96 で割った値から濃さを AO_STRENGTH×(1 − h÷96)² + 0.05 と決め、contactRadius は半径を(AO_RADIUS + 半幅×0.4)×(1 + 1.2×h÷96)で広げます。半幅26の球は床に触れると濃さ0.71・半径36.4で、いちばん高い96では濃さ0.05・半径80.1までほどけ、この影はDOMの box-shadow ではなくキャンバスに塗った楕円です。測定は累積面積16.8%・平均強度41.9・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="物体と床の間の高さ一つから接触影の半径と濃さを式で出し、近いほど小さく濃く、遠いほど広く薄く塗る。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">物体と床の間の高さ一つから接触影の半径と濃さを式で出し、近いほど小さく濃く、遠いほど広く薄く塗る。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const AO_RADIUS = 26;
const AO_STRENGTH = 0.66;
const HEIGHT_MAX = 96;
const OBJECT_COUNT = 3;
const LIGHT_LERP = 0.16;
const GROUND_Y = 220;
const FLOOR_RGB = [243, 238, 250];
const INK_RGB = [66, 48, 112];
const OBJ_XS = [118, 240, 362];
const OBJ_HALF = [26, 24, 20];
const OBJ_REACH = [1, 0.72, 0.88];
const OBJ_FOLLOW = [0.12, 0.09, 0.07];
const OBJ_RGBS = [[255, 206, 190], [186, 222, 255], [200, 240, 214]];
const OBJ_EDGE = [[232, 150, 130], [120, 170, 232], [120, 208, 160]];
// 높이 h 의 물체 아래 접촉 그림자 — 가까울수록 작고 진하고, 멀수록 넓고 옅다
function contactAlpha(h) {
const near = 1 - clamp(h / HEIGHT_MAX, 0, 1);
return AO_STRENGTH * near * near + 0.05;
}
function contactRadius(h, half) {
return (AO_RADIUS + half * 0.4) * (1 + 1.2 * clamp(h / HEIGHT_MAX, 0, 1));
}
function makeScene(ctx) {
let lx = 240;
let ly = 150;
const level = [];
for (let i = 0; i < OBJECT_COUNT; i += 1) {
level.push(0.5);
}
function follow(k) {
const s = clamp((ly - 20) / 260, 0, 1);
for (let i = 0; i < OBJECT_COUNT; i += 1) {
level[i] = lerp(level[i], s, k * OBJ_FOLLOW[i] / 0.12);
}
}
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 140 * Math.sin(a), 150 + 138 * Math.sin(a * 1.0 - 1.57)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
follow(0.16);
},
snap: function (tx, ty) {
lx = tx;
ly = ty;
for (let i = 0; i < OBJECT_COUNT; i += 1) {
level[i] = clamp((ly - 20) / 260, 0, 1);
}
},
draw: function () {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const wall = ctx.createLinearGradient(0, 0, 0, GROUND_Y - 30);
wall.addColorStop(0, 'rgba(226,214,250,1)');
wall.addColorStop(1, 'rgba(255,244,226,1)');
ctx.fillStyle = wall;
ctx.fillRect(0, 0, W, GROUND_Y - 30);
for (let i = 0; i < OBJECT_COUNT; i += 1) {
const h = HEIGHT_MAX * level[i] * OBJ_REACH[i];
const half = OBJ_HALF[i];
const x = OBJ_XS[i];
const rx = contactRadius(h, half);
const a = contactAlpha(h);
// 번진 바깥 층과 맞닿은 안쪽 핵 — 두 반지름이 높이에 따라 따로 변한다
ctx.save();
ctx.translate(x, GROUND_Y);
ctx.scale(1, 0.3);
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
g.addColorStop(0, rgba(INK_RGB, a));
g.addColorStop(0.55, rgba(INK_RGB, a * 0.45));
g.addColorStop(1, rgba(INK_RGB, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, rx, 0, PI2);
ctx.fill();
ctx.restore();
const cy = GROUND_Y - h * 0.8 - half;
if (i === 1) {
ctx.fillStyle = rgba(OBJ_RGBS[i], 1);
ctx.fillRect(x - half, cy - half, half * 2, half * 2);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(OBJ_EDGE[i], 1);
ctx.strokeRect(x - half, cy - half, half * 2, half * 2);
}
else {
const sg = ctx.createRadialGradient(x - half * 0.35, cy - half * 0.4, half * 0.1, x, cy, half);
sg.addColorStop(0, 'rgba(255,255,255,1)');
sg.addColorStop(0.35, rgba(OBJ_RGBS[i], 1));
sg.addColorStop(1, rgba(OBJ_EDGE[i], 1));
ctx.fillStyle = sg;
ctx.beginPath();
ctx.arc(x, cy, half, 0, PI2);
ctx.fill();
}
ctx.fillStyle = rgba(INK_RGB, 0.7);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('h ' + String(Math.round(h)), x, GROUND_Y + 22);
}
glow(ctx, lx, 24, 30, [255, 226, 150], 0.9);
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastContactOcclusionDarken.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const AO_RADIUS = 26;
const AO_STRENGTH = 0.66;
const HEIGHT_MAX = 96;
const OBJECT_COUNT = 3;
const LIGHT_LERP = 0.16;
const GROUND_Y = 220;
const FLOOR_RGB = [243, 238, 250];
const INK_RGB = [66, 48, 112];
const OBJ_XS = [118, 240, 362];
const OBJ_HALF = [26, 24, 20];
const OBJ_REACH = [1, 0.72, 0.88];
const OBJ_FOLLOW = [0.12, 0.09, 0.07];
const OBJ_RGBS = [[255, 206, 190], [186, 222, 255], [200, 240, 214]];
const OBJ_EDGE = [[232, 150, 130], [120, 170, 232], [120, 208, 160]];
// 높이 h 의 물체 아래 접촉 그림자 — 가까울수록 작고 진하고, 멀수록 넓고 옅다
function contactAlpha(h: number): number {
const near = 1 - clamp(h / HEIGHT_MAX, 0, 1);
return AO_STRENGTH * near * near + 0.05;
}
function contactRadius(h: number, half: number): number {
return (AO_RADIUS + half * 0.4) * (1 + 1.2 * clamp(h / HEIGHT_MAX, 0, 1));
}
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 240;
let ly = 150;
const level: number[] = [];
for (let i = 0; i < OBJECT_COUNT; i += 1) { level.push(0.5); }
function follow(k: number): void {
const s = clamp((ly - 20) / 260, 0, 1);
for (let i = 0; i < OBJECT_COUNT; i += 1) { level[i] = lerp(level[i], s, k * OBJ_FOLLOW[i] / 0.12); }
}
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 140 * Math.sin(a), 150 + 138 * Math.sin(a * 1.0 - 1.57)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
follow(0.16);
},
snap: function (tx: number, ty: number): void {
lx = tx;
ly = ty;
for (let i = 0; i < OBJECT_COUNT; i += 1) { level[i] = clamp((ly - 20) / 260, 0, 1); }
},
draw: function (): void {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const wall = ctx.createLinearGradient(0, 0, 0, GROUND_Y - 30);
wall.addColorStop(0, 'rgba(226,214,250,1)');
wall.addColorStop(1, 'rgba(255,244,226,1)');
ctx.fillStyle = wall;
ctx.fillRect(0, 0, W, GROUND_Y - 30);
for (let i = 0; i < OBJECT_COUNT; i += 1) {
const h = HEIGHT_MAX * level[i] * OBJ_REACH[i];
const half = OBJ_HALF[i];
const x = OBJ_XS[i];
const rx = contactRadius(h, half);
const a = contactAlpha(h);
// 번진 바깥 층과 맞닿은 안쪽 핵 — 두 반지름이 높이에 따라 따로 변한다
ctx.save();
ctx.translate(x, GROUND_Y);
ctx.scale(1, 0.3);
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
g.addColorStop(0, rgba(INK_RGB, a));
g.addColorStop(0.55, rgba(INK_RGB, a * 0.45));
g.addColorStop(1, rgba(INK_RGB, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, rx, 0, PI2);
ctx.fill();
ctx.restore();
const cy = GROUND_Y - h * 0.8 - half;
if (i === 1) {
ctx.fillStyle = rgba(OBJ_RGBS[i], 1);
ctx.fillRect(x - half, cy - half, half * 2, half * 2);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(OBJ_EDGE[i], 1);
ctx.strokeRect(x - half, cy - half, half * 2, half * 2);
} else {
const sg = ctx.createRadialGradient(x - half * 0.35, cy - half * 0.4, half * 0.1, x, cy, half);
sg.addColorStop(0, 'rgba(255,255,255,1)');
sg.addColorStop(0.35, rgba(OBJ_RGBS[i], 1));
sg.addColorStop(1, rgba(OBJ_EDGE[i], 1));
ctx.fillStyle = sg;
ctx.beginPath();
ctx.arc(x, cy, half, 0, PI2);
ctx.fill();
}
ctx.fillStyle = rgba(INK_RGB, 0.7);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('h ' + String(Math.round(h)), x, GROUND_Y + 22);
}
glow(ctx, lx, 24, 30, [255, 226, 150], 0.9);
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastContactOcclusionDarken() {
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>
<p className={styles.sc__caption}>물체와 바닥 사이 높이 하나에서 접촉 그림자의 반지름과 농도를 식으로 뽑아, 가까울수록 작고 짙게, 멀수록 넓고 옅게 칠한다.</p>
</div>
</main>
);
}07 · 物体が降りるほど濃くなる接触影 — 計測 累積面積 16.829% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function contactAlpha(h) {
const near = 1 - clamp(h / HEIGHT_MAX, 0, 1);
return AO_STRENGTH * near * near + 0.05;
}
function contactRadius(h, half) {
return (AO_RADIUS + half * 0.4) * (1 + 1.2 * clamp(h / HEIGHT_MAX, 0, 1));
}
08二色の光が重なってできる二重の影
光ごとにオフスクリーンの層へ色の溜まりを描き、destination-out で自分の影の多角形だけを消してから、lighter で本体のキャンバスに足します。だから片方の光だけが遮られた所にはもう一方の光の色が残り、二つとも遮られた所だけが床の色のまま最も暗くなります。測定は累積面積66.3%・平均強度34.1・23/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="光源二つがそれぞれ影を作り、両方の光が遮られた所だけを最も暗く計算する。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">光源二つがそれぞれ影を作り、両方の光が遮られた所だけを最も暗く計算する。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_A_HUE = 330;
const LIGHT_B_HUE = 170;
const LIGHT_GAP = 160;
const LIGHT_HEIGHT = 190;
const AXIS_SPEED = 0.0008;
const LIGHT_LERP = 0.14;
const POOL_RADIUS = 300;
const FLOOR_RGB = [58, 50, 86];
const SHADOW_FLOOR = [58, 50, 86];
const BLOCKS = [[132, 112, 168, 136, 58], [222, 170, 262, 196, 66], [330, 100, 366, 124, 54]];
const BLOCK_TOPS = [[255, 226, 214], [255, 244, 204], [214, 244, 232]];
const BLOCK_FRONTS = [[226, 160, 150], [226, 200, 120], [130, 200, 176]];
function lightColor(hue, a) { return 'hsla(' + hue + ',88%,76%,' + a.toFixed(3) + ')'; }
function makeScene(ctx) {
const layer = document.createElement('canvas');
layer.width = W;
layer.height = H;
const lctx0 = layer.getContext('2d');
if (!lctx0) {
throw new Error('canvas 2d unavailable');
}
const lctx = lctx0;
let cx = 240;
let cy = 150;
function lights(t) {
const a = t * AXIS_SPEED;
const dx = Math.cos(a) * LIGHT_GAP / 2;
const dy = Math.sin(a) * LIGHT_GAP / 2;
return [
[clamp(cx + dx, 10, W - 10), clamp(cy + dy, 10, H - 10)],
[clamp(cx - dx, 10, W - 10), clamp(cy - dy, 10, H - 10)]
];
}
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 70 * Math.sin(a), 150 + 56 * Math.sin(a * 2)];
},
pos: function () { return [cx, cy]; },
update: function (tx, ty) {
cx = lerp(cx, tx, LIGHT_LERP);
cy = lerp(cy, ty, LIGHT_LERP);
},
snap: function (tx, ty) { cx = tx; cy = ty; },
draw: function (t) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const ls = lights(t);
const hues = [LIGHT_A_HUE, LIGHT_B_HUE];
for (let i = 0; i < 2; i += 1) {
// 광원마다 빛 웅덩이에서 자기 그림자만 지운 층을 만들어 더한다
lctx.globalCompositeOperation = 'source-over';
lctx.clearRect(0, 0, W, H);
const g = lctx.createRadialGradient(ls[i][0], ls[i][1], 0, ls[i][0], ls[i][1], POOL_RADIUS);
g.addColorStop(0, lightColor(hues[i], 0.62));
g.addColorStop(1, lightColor(hues[i], 0.05));
lctx.fillStyle = g;
lctx.fillRect(0, 0, W, H);
lctx.globalCompositeOperation = 'destination-out';
lctx.fillStyle = 'rgba(0,0,0,1)';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
const poly = boxShadow(ls[i][0], ls[i][1], LIGHT_HEIGHT, r[0], r[1], r[2], r[3], r[4]);
polyPath(lctx, poly);
lctx.fill();
}
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(layer, 0, 0);
}
ctx.globalCompositeOperation = 'source-over';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], BLOCK_TOPS[b], BLOCK_FRONTS[b]);
}
for (let i = 0; i < 2; i += 1) {
glow(ctx, ls[i][0], ls[i][1], 28, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(ls[i][0], ls[i][1], 7, 0, PI2);
ctx.fillStyle = lightColor(hues[i], 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.stroke();
}
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastTwoColoredLightsOverlap.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_A_HUE = 330;
const LIGHT_B_HUE = 170;
const LIGHT_GAP = 160;
const LIGHT_HEIGHT = 190;
const AXIS_SPEED = 0.0008;
const LIGHT_LERP = 0.14;
const POOL_RADIUS = 300;
const FLOOR_RGB = [58, 50, 86];
const SHADOW_FLOOR = [58, 50, 86];
const BLOCKS = [[132, 112, 168, 136, 58], [222, 170, 262, 196, 66], [330, 100, 366, 124, 54]];
const BLOCK_TOPS = [[255, 226, 214], [255, 244, 204], [214, 244, 232]];
const BLOCK_FRONTS = [[226, 160, 150], [226, 200, 120], [130, 200, 176]];
function lightColor(hue: number, a: number): string { return 'hsla(' + hue + ',88%,76%,' + a.toFixed(3) + ')'; }
function makeScene(ctx: CanvasRenderingContext2D): Scene {
const layer = document.createElement('canvas');
layer.width = W;
layer.height = H;
const lctx0 = layer.getContext('2d');
if (!lctx0) { throw new Error('canvas 2d unavailable'); }
const lctx: CanvasRenderingContext2D = lctx0;
let cx = 240;
let cy = 150;
function lights(t: number): Pt[] {
const a = t * AXIS_SPEED;
const dx = Math.cos(a) * LIGHT_GAP / 2;
const dy = Math.sin(a) * LIGHT_GAP / 2;
return [
[clamp(cx + dx, 10, W - 10), clamp(cy + dy, 10, H - 10)],
[clamp(cx - dx, 10, W - 10), clamp(cy - dy, 10, H - 10)]
];
}
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 70 * Math.sin(a), 150 + 56 * Math.sin(a * 2)];
},
pos: function (): Pt { return [cx, cy]; },
update: function (tx: number, ty: number): void {
cx = lerp(cx, tx, LIGHT_LERP);
cy = lerp(cy, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { cx = tx; cy = ty; },
draw: function (t: number): void {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const ls = lights(t);
const hues = [LIGHT_A_HUE, LIGHT_B_HUE];
for (let i = 0; i < 2; i += 1) {
// 광원마다 빛 웅덩이에서 자기 그림자만 지운 층을 만들어 더한다
lctx.globalCompositeOperation = 'source-over';
lctx.clearRect(0, 0, W, H);
const g = lctx.createRadialGradient(ls[i][0], ls[i][1], 0, ls[i][0], ls[i][1], POOL_RADIUS);
g.addColorStop(0, lightColor(hues[i], 0.62));
g.addColorStop(1, lightColor(hues[i], 0.05));
lctx.fillStyle = g;
lctx.fillRect(0, 0, W, H);
lctx.globalCompositeOperation = 'destination-out';
lctx.fillStyle = 'rgba(0,0,0,1)';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
const poly = boxShadow(ls[i][0], ls[i][1], LIGHT_HEIGHT, r[0], r[1], r[2], r[3], r[4]);
polyPath(lctx, poly);
lctx.fill();
}
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(layer, 0, 0);
}
ctx.globalCompositeOperation = 'source-over';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], BLOCK_TOPS[b], BLOCK_FRONTS[b]);
}
for (let i = 0; i < 2; i += 1) {
glow(ctx, ls[i][0], ls[i][1], 28, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(ls[i][0], ls[i][1], 7, 0, PI2);
ctx.fillStyle = lightColor(hues[i], 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.stroke();
}
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastTwoColoredLightsOverlap() {
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>
<p className={styles.sc__caption}>광원 두 개가 각자 그림자를 만들고, 두 빛이 모두 막힌 곳만 가장 어둡게 계산한다.</p>
</div>
</main>
);
}08 · 二色の光源が重なって作る二重の影 — 計測 累積面積 66.294% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function lights(t) {
const a = t * AXIS_SPEED;
const dx = Math.cos(a) * LIGHT_GAP / 2;
const dy = Math.sin(a) * LIGHT_GAP / 2;
return [
[clamp(cx + dx, 10, W - 10), clamp(cy + dy, 10, H - 10)],
[clamp(cx - dx, 10, W - 10), clamp(cy - dy, 10, H - 10)]
];
}
lctx.globalCompositeOperation = 'source-over';
lctx.clearRect(0, 0, W, H);
const g = lctx.createRadialGradient(ls[i][0], ls[i][1], 0, ls[i][0], ls[i][1], POOL_RADIUS);
g.addColorStop(0, lightColor(hues[i], 0.62));
g.addColorStop(1, lightColor(hues[i], 0.05));
lctx.fillStyle = g;
lctx.fillRect(0, 0, W, H);
lctx.globalCompositeOperation = 'destination-out';
lctx.fillStyle = 'rgba(0,0,0,1)';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
const poly = boxShadow(ls[i][0], ls[i][1], LIGHT_HEIGHT, r[0], r[1], r[2], r[3], r[4]);
polyPath(lctx, poly);
lctx.fill();
}
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(layer, 0, 0);
}
09日が暮れてランプが点く机のヒーロー
進み具合ひとつから太陽の高さを420から70へ lerp し、ランプの点灯を smooth((p−0.35)÷0.4)、太陽の濃さを 1 − 0.9×smooth((p−0.55)÷0.45) で決めて、同じ箱三つに太陽の影とランプの影を別々に重ねて塗ります。進み0.75では太陽の高さ124.7・ランプ点灯1・太陽の濃さ0.625で二つの影が同時に見え、進み1では太陽の濃さ0.1だけが残ります。測定は累積面積70.1%・平均強度21.4・18/23フレームです。

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="先の投影・ぼかし・太陽の高さの計算を一つの場面にまとめ、太陽の影が伸びて消えるとランプの影が引き継ぐ。"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">デモ実行中</p>
<button class="sc__btn" id="scBtn" type="button">一時停止</button>
<p class="sc__caption">先の投影・ぼかし・太陽の高さの計算を一つの場面にまとめ、太陽の影が伸びて消えるとランプの影が引き継ぐ。</p>
</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;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@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;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
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;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const DUSK_FRAMES = 125;
const LAMP_HEIGHT = 150;
const SUN_HEIGHT_NOON = 420;
const SUN_HEIGHT_DUSK = 70;
const LIGHT_LERP = 0.2;
const DUSK_MS = DUSK_FRAMES * FIXED_DT;
const DESK_DAY = [244, 226, 200];
const DESK_NIGHT = [52, 46, 92];
const INK_RGB = [66, 48, 112];
const SUN_RGB = [255, 206, 120];
const LAMP_RGB = [255, 214, 140];
const LAMP_SPOT = [404, 74];
const SUN_SPOT = [-60, 96];
const ITEMS = [[104, 168, 188, 218, 16], [236, 188, 270, 222, 40], [312, 128, 344, 160, 56]];
const ITEM_TOPS = [[255, 214, 204], [190, 226, 255], [180, 240, 216]];
const ITEM_FRONTS = [[226, 150, 140], [120, 176, 230], [110, 200, 160]];
function makeScene(ctx) {
let px = 0;
const scene = {
path: function (t) {
const c = (t / (DUSK_MS * 2)) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * smooth(tri), 150];
},
pos: function () { return [px, 150]; },
update: function (tx) { px = lerp(px, tx, LIGHT_LERP); },
snap: function (tx) { px = tx; },
draw: function () {
const p = clamp(px / W, 0, 1);
const night = smooth(p);
const lampOn = smooth((p - 0.35) / 0.4);
const sunAlpha = 1 - 0.9 * smooth((p - 0.55) / 0.45);
const desk = [lerp(DESK_DAY[0], DESK_NIGHT[0], night), lerp(DESK_DAY[1], DESK_NIGHT[1], night), lerp(DESK_DAY[2], DESK_NIGHT[2], night)];
ctx.fillStyle = rgba(desk, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, SUN_SPOT[0] + 20, SUN_SPOT[1], 380, SUN_RGB, 0.7 * sunAlpha);
glow(ctx, LAMP_SPOT[0], LAMP_SPOT[1], 280, LAMP_RGB, 0.9 * lampOn);
const sunH = lerp(SUN_HEIGHT_NOON, SUN_HEIGHT_DUSK, smooth(p));
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
fillShadow(ctx, boxShadow(SUN_SPOT[0], SUN_SPOT[1], sunH, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.4 * sunAlpha);
fillShadow(ctx, boxShadow(LAMP_SPOT[0], LAMP_SPOT[1], LAMP_HEIGHT, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.5 * lampOn);
}
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], ITEM_TOPS[i], ITEM_FRONTS[i]);
}
// 램프 머리 — 켜지면 빛이 번진다
ctx.beginPath();
ctx.arc(LAMP_SPOT[0], LAMP_SPOT[1], 15, 0, PI2);
ctx.fillStyle = rgba([lerp(236, 255, lampOn), lerp(214, 244, lampOn), lerp(206, 186, lampOn)], 1);
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(200,170,120,0.95)';
ctx.stroke();
// 해의 높이를 알려 주는 창 표시
glow(ctx, 20, SUN_SPOT[1], 36, SUN_RGB, 0.9 * sunAlpha);
ctx.fillStyle = rgba(SUN_RGB, Math.max(sunAlpha, 0.15));
ctx.fillRect(0, SUN_SPOT[1] - 22, 8, 44);
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '一場面ずつ送っています';
btnEl.textContent = '次の場面';
return;
}
statusEl.textContent = !autoplay ? '手動操作中' : (paused ? '一時停止中' : 'デモ実行中');
btnEl.textContent = (autoplay && !paused) ? '一時停止' : '再生';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastDeskLampDuskHeroScene.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
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 rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
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 glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const DUSK_FRAMES = 125;
const LAMP_HEIGHT = 150;
const SUN_HEIGHT_NOON = 420;
const SUN_HEIGHT_DUSK = 70;
const LIGHT_LERP = 0.2;
const DUSK_MS = DUSK_FRAMES * FIXED_DT;
const DESK_DAY = [244, 226, 200];
const DESK_NIGHT = [52, 46, 92];
const INK_RGB = [66, 48, 112];
const SUN_RGB = [255, 206, 120];
const LAMP_RGB = [255, 214, 140];
const LAMP_SPOT = [404, 74];
const SUN_SPOT = [-60, 96];
const ITEMS = [[104, 168, 188, 218, 16], [236, 188, 270, 222, 40], [312, 128, 344, 160, 56]];
const ITEM_TOPS = [[255, 214, 204], [190, 226, 255], [180, 240, 216]];
const ITEM_FRONTS = [[226, 150, 140], [120, 176, 230], [110, 200, 160]];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let px = 0;
const scene: Scene = {
path: function (t: number): Pt {
const c = (t / (DUSK_MS * 2)) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * smooth(tri), 150];
},
pos: function (): Pt { return [px, 150]; },
update: function (tx: number): void { px = lerp(px, tx, LIGHT_LERP); },
snap: function (tx: number): void { px = tx; },
draw: function (): void {
const p = clamp(px / W, 0, 1);
const night = smooth(p);
const lampOn = smooth((p - 0.35) / 0.4);
const sunAlpha = 1 - 0.9 * smooth((p - 0.55) / 0.45);
const desk = [lerp(DESK_DAY[0], DESK_NIGHT[0], night), lerp(DESK_DAY[1], DESK_NIGHT[1], night), lerp(DESK_DAY[2], DESK_NIGHT[2], night)];
ctx.fillStyle = rgba(desk, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, SUN_SPOT[0] + 20, SUN_SPOT[1], 380, SUN_RGB, 0.7 * sunAlpha);
glow(ctx, LAMP_SPOT[0], LAMP_SPOT[1], 280, LAMP_RGB, 0.9 * lampOn);
const sunH = lerp(SUN_HEIGHT_NOON, SUN_HEIGHT_DUSK, smooth(p));
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
fillShadow(ctx, boxShadow(SUN_SPOT[0], SUN_SPOT[1], sunH, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.4 * sunAlpha);
fillShadow(ctx, boxShadow(LAMP_SPOT[0], LAMP_SPOT[1], LAMP_HEIGHT, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.5 * lampOn);
}
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], ITEM_TOPS[i], ITEM_FRONTS[i]);
}
// 램프 머리 — 켜지면 빛이 번진다
ctx.beginPath();
ctx.arc(LAMP_SPOT[0], LAMP_SPOT[1], 15, 0, PI2);
ctx.fillStyle = rgba([lerp(236, 255, lampOn), lerp(214, 244, lampOn), lerp(206, 186, lampOn)], 1);
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(200,170,120,0.95)';
ctx.stroke();
// 해의 높이를 알려 주는 창 표시
glow(ctx, 20, SUN_SPOT[1], 36, SUN_RGB, 0.9 * sunAlpha);
ctx.fillStyle = rgba(SUN_RGB, Math.max(sunAlpha, 0.15));
ctx.fillRect(0, SUN_SPOT[1] - 22, 8, 44);
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastDeskLampDuskHeroScene() {
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>
<p className={styles.sc__caption}>앞의 투영·흐림·해 높이 계산을 한 장면에 모아, 해 그림자가 길어지다 사라지면 램프 그림자가 이어받는다.</p>
</div>
</main>
);
}09 · 日が沈みランプが灯る机のヒーロー — 計測 累積面積 70.11% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const night = smooth(p);
const lampOn = smooth((p - 0.35) / 0.4);
const sunAlpha = 1 - 0.9 * smooth((p - 0.55) / 0.45);
const desk = [lerp(DESK_DAY[0], DESK_NIGHT[0], night), lerp(DESK_DAY[1], DESK_NIGHT[1], night), lerp(DESK_DAY[2], DESK_NIGHT[2], night)];
ctx.fillStyle = rgba(desk, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, SUN_SPOT[0] + 20, SUN_SPOT[1], 380, SUN_RGB, 0.7 * sunAlpha);
glow(ctx, LAMP_SPOT[0], LAMP_SPOT[1], 280, LAMP_RGB, 0.9 * lampOn);
const sunH = lerp(SUN_HEIGHT_NOON, SUN_HEIGHT_DUSK, smooth(p));
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
fillShadow(ctx, boxShadow(SUN_SPOT[0], SUN_SPOT[1], sunH, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.4 * sunAlpha);
fillShadow(ctx, boxShadow(LAMP_SPOT[0], LAMP_SPOT[1], LAMP_HEIGHT, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.5 * lampOn);
}
値が場面を変えるところ — 光源の高さ・光線の数・二つの光の間隔
触ると場面が変わる値は三つです。01の光源の高さ LIGHT_HEIGHT、05の光線の数 RAY_COUNT、08の二つの光の間隔 LIGHT_GAP で、ぼかし具合は03の BLUR_PER_HEIGHT が、cssでいう影の透明度にあたる濃さは06の SHADOW_ALPHA_BASE と ELEV_STEP が決めます。
光源を低くすると影が長く伸びる
LIGHT_HEIGHT 220では箱の上面(高さ70)の倍率は220÷150=1.47です。120に下げると120÷50=2.4になって影がずっと長く伸び、400に上げると400÷330=1.21になって箱のすぐ脇に収まります。css 影 下だけ のように足元へ寄せた影にしたいなら、いちばん効くのは光の高さです。
光線を倍にしても光の形は変わらない
05の RAY_COUNT を180から360に増やすと、多角形の頂点は264個(180+3×28)から444個に増えますが、光の届く形は同じままです。形を決めるのは壁の端点へ向けて放った光線で、等間隔の光線は直線の辺を割るだけなので、増えるのは光線×線分の交差計算の費用だけです。
二つの光の間隔を広げると重なりが減る
LIGHT_GAP を160から260へ広げると、二つの光(色相330と170)の影が箱ごとに分かれる幅が大きくなり、両方の光が遮られて最も暗い重なりの領域は減ります。二つの光は中点を基準に AXIS_SPEED 0.0008ラジアン/msで回る軸の上にあり、間隔はその軸に沿って開きます。
九つを描画して測った値は次の表にまとめました。04と07を除く七つの場面は累積面積が30%を超え、01・05・08・09は60%を超えます。接触の影ひとつだけの07が16.8%でいちばん小さくても、10%を下回った場面はありません。動いたフレームは09だけが18/23で、残りは23/23なので、デモの間じゅう画面はほとんど休まず変わり続けます。
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 光の軌道の投影 | 63.4% | 40.4 | 23/23 |
| 02 ポインタの柱の列 | 35.0% | 44.0 | 23/23 |
| 03 高さ別のぼかし | 32.8% | 44.9 | 23/23 |
| 04 日時計 | 28.5% | 37.1 | 23/23 |
| 05 壁の遮蔽多角形 | 65.6% | 74.3 | 23/23 |
| 06 段の高さのカード | 41.6% | 28.5 | 23/23 |
| 07 接触の影 | 16.8% | 41.9 | 23/23 |
| 08 二色の光 | 66.3% | 34.1 | 23/23 |
| 09 ランプのヒーロー | 70.1% | 21.4 | 18/23 |
どこで壊れるか — 狭い画面・光源が物体の中に入るとき・読めないコントラスト
光源が物体の高さまで下がると倍率が跳ねる
01で光源の高さが箱の高さ70以下になると、分母 lh − h が8に固定されて倍率が70÷8=8.75へ跳ねます。光が物体より低いと式そのものが成り立たないので、この固定は安全装置にすぎず、高さは物体より上に置きます。02でも最も高い柱(101)の近くまで LIGHT_HEIGHT を下げると、同じ固定が先にかかります。
太陽が地平線に近いと影が切れる
04で太陽の進みが0.04だと高度は21.0°で、本来の長さは156.2ですが SHADOW_LEN_MAX の140で切れます。同じ場面で進み0.25は高度53.6°・長さ44.2、進み0.5は高度70°・長さ21.8なので、真昼がいちばん影が短くなります。
壁の隙間が狭いと光源が入れない
05は壁の間の隙間が WALL_MARGIN 4の二倍(8)より狭いと、光源が隙間に入れません。pushOut は壁の矩形をマージンぶん広げた範囲に光源が入ると、最も近い外側の辺へ押し出す動作です。
縁では二つの光が近づく
08の二つの光は、それぞれキャンバスの縁から10px内側に収められます。中点が縁の近くへ行くと実際の間隔が LIGHT_GAP より縮み、同じ値でも重なりの領域が大きくなります。
暗い床では影の色をそのまま使えない
01は明るい床 [243, 238, 250] に影の色 [74, 56, 120] を濃さ0.46で重ねます。同じ値を暗い紫の床へ移すと影が床に溶けて読めません。08の床が暗い紫 [58, 50, 86] なのは色光の重なりを見せるための場面上の例外で、08は影を塗らずに光を足す方式なので、暗い床でも重なりが読めます。
狭い画面と動きの判定
キャンバスは aspect-ratio 480 / 300 で幅だけが縮み、九つの場面の横あふれ・スマホ幅あふれの判定はすべていいえです。09の動いたフレームが18/23なのは、デモの仮想の経路が左右の幅全体を往復して両端で減速し、フレームの間で変化のない区間ができるからです。
自分で値を変えて境界を確かめる
02の光源の高さと01の箱の高さ、04の上限と05のマージンを一つずつ変えれば、上の境界が現れる値が見つかります。どの値でどう崩れるかを手で確かめたいなら、下のファイルをパスワード 6bvjpvkj で開いて、これらの定数を順に書き換えてみてください。
バリエーション三つとアクセシビリティ
| 名前 | 値 | 感じ |
|---|---|---|
| 光源の高さを変える | 01 LIGHT_HEIGHT 220 → 120 | 箱の上面が床に落ちる距離の倍率が1.47(220÷150)から2.4(120÷50)に増えて影がずっと長く伸び、400に上げると1.21倍に減って短くなります。 |
| 光線の数を増やす | 05 RAY_COUNT 180 → 360 | 多角形の頂点は264個から444個に増えますが、光の届く形は同じです。増えるのは光線×線分の交差計算の費用です。 |
| 二つの光の間隔を変える | 08 LIGHT_GAP 160 → 260 | 二つの光(色相330と170)の影が箱ごとに分かれる幅が広がり、両方の光が遮られて暗い重なりの領域は減ります。 |
prefers-reduced-motion が有効だと、九つの場面すべてで requestAnimationFrame のループを始めず、1000ミリ秒の時点の絵を一枚だけ描きます。この設定ではボタンの文字が「次の場面」に変わり、押すたびに経路が900ミリ秒だけ進んで補間なしにすぐ描かれ、ポインタや矢印キーの入力も同じようにその場で反映されます。通常の動きではキャンバスを押すまでポインタは光を動かしませんが、この設定ではポインタを動かすだけで光が追います。キャンバスは tabindex 0 なのでフォーカスを受け取り、矢印キーは光を12pxずつ動かし、Enterとスペースはボタンと同じ働きをします。状態の文字は aria-live="polite" の一行で、デモ中・一時停止・直接操作中の切り替えが読み上げられ、ボタンは <button> ひとつで停止と再生を行き来します。
css 影をつけるのはbox-shadow一行で足りるか?
光が固定で形が四角いカードなら足ります。box-shadow はオフセット・ぼかし・色の三つで済み、動かない一覧のカードに光の計算を足しても費用が増えるだけです。値が光の位置に結びつく必要が出たところから、計算の出番になります。
場面ごとに合う置き場所は違います。01は商品の箱を見せる場面、02と09はランディングのヒーロー背景、03は浮いたカードやフローティングボタン、04は時間帯を紹介するセクション、05はダンジョンの地図や部屋の構造の紹介、06はダッシュボードのパネルやカードの重なり、07は机の上の小物や商品写真の合成、08はステージ照明のようなイベントバナーに向きます。06の三つの値はそのままCSSへ移せますが、07は楕円のグラデーションなので、box-shadow の値ではなく濃さと半径の式を借ります。
回転を計算して物を描く場面はサイコロ アニメーション 9パターン — 回転行列で上の目を読む3D場面とコマ 回転 9パターン — 角速度と歳差で描く回転シーンへ続き、影を計算する側は、この編が光ひとつと二つまでを扱います。
css 影 おしゃれ を作る前によくある質問
css 影 内側に落とす影も光から計算できますか?
九つの場面に内側へ落とす影はなく、box-shadow の inset を置き換えるものではありません。ここで計算しているのは物が床に落とす影なので、内側の影が欲しいときは inset のまま、濃さとぼかしの目安だけを06の shadowOf から借りるのが近道です。
自分のページに貼るには何から変えますか?
場面を一つ選び、その index.html の上のほうにある LIGHT_HEIGHT と色の配列から書き換えてください。キャンバスは480×300の比率なので、置く場所に合わせてCSSの幅だけ調整すれば収まります。
ライブラリや画像は必要ですか?
いずれも不要です。影も床も箱もキャンバスの2Dで描いていて、外へ出ていく通信がないので、一つのファイルを置くだけで動きます。









