09アーチ橋シーン — マウス角度でなぞる橋
アーチ区間をサイン曲線で計算し、欄干とともにz順に描く橋のシーンです。
使いどころインフラ・都市の紹介ページ
rotate3dsinlineTo

<main class="stage">
<div class="ab is-demo" id="abRoot">
<p class="ab__hint">
<svg class="ab__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M20 180h216v20H20z"/><path d="M226 168h-14a86 86 0 0 0-168 0H30a8 8 0 0 0 0 16h196a8 8 0 0 0 0-16Zm-98-70a70.08 70.08 0 0 1 69.4 62H58.6A70.08 70.08 0 0 1 128 98Z"/></g></svg>
<span>アーチがサインカーブで弧を描いて上がります — ポインター左右で視点が回り、上下で角度が変わります</span>
</p>
<div class="ab__field" id="abField" role="application" aria-label="アーチ橋遠近実験盤" tabindex="0">
<canvas class="ab__cv" id="abCv"></canvas>
</div>
<div class="ab__bar">
<label class="ab__dial">アーチの高さ
<input type="range" id="abHeight" min="60" max="160" step="10" value="110" aria-label="アーチの高さ">
<b class="ab__val" id="abHeightVal">110px</b>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 아치 교량 — 무대는 밝은 종이. 움직임은 전부 캔버스 rAF 루프 안에 있다.
// @keyframes 를 두지 않는다 — 렌더러가 실시간 24장을 찍는다.
$duration: $dur-loop; // 변형 축: 시간
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-paper);
.ab {
--ab-pink: #{$subject-pink};
--ab-mint: #{$subject-mint};
--ab-lilac: #{$subject-lilac};
--ab-sky: #{$subject-sky};
--ab-cream: #{$subject-cream};
--ab-ink: #{$subject-ink};
--ab-deck: #{rgba($subject-ink, .06)};
--ab-rail: #{rgba($subject-ink, .38)};
--ab-water: #{rgba($subject-sky, .55)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ab__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.ab__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-sky; }
.ab__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .12);
cursor: crosshair; touch-action: none;
}
.ab__field:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.ab__cv { display: block; width: 100%; height: 100%; }
.ab__bar { display: flex; align-items: center; gap: $sp-3; }
.ab__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.ab__dial input { width: 116px; margin: 0; accent-color: $subject-blue-deep; }
.ab__val { color: $subject-blue-deep; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.ab { gap: $sp-1; }
.ab__field { height: 100px; }
.ab__dial { gap: $sp-1; }
.ab__dial input { width: 68px; }
.ab__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말 게 없다. 멈춘 한 장이 남으니 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.ab.is-still .ab__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ab {
--ab-pink: #ff9ebb;
--ab-mint: #4cd4a6;
--ab-lilac: #b9a0ff;
--ab-sky: #6ec8ff;
--ab-cream: #fff7e6;
--ab-ink: #17141a;
--ab-deck: rgba(23, 20, 26, 0.06);
--ab-rail: rgba(23, 20, 26, 0.38);
--ab-water: rgba(110, 200, 255, 0.55);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ab__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.ab__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #6ec8ff;
}
.ab__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
cursor: crosshair;
touch-action: none;
}
.ab__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.ab__cv {
display: block;
width: 100%;
height: 100%;
}
.ab__bar {
display: flex;
align-items: center;
gap: 12px;
}
.ab__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.ab__dial input {
width: 116px;
margin: 0;
accent-color: #0a4fe8;
}
.ab__val {
color: #0a4fe8;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.ab {
gap: 4px;
}
.ab__field {
height: 100px;
}
.ab__dial {
gap: 4px;
}
.ab__dial input {
width: 68px;
}
.ab__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ab.is-still .ab__field {
cursor: default !important;
}
}// 아치 교량 — 아치 단면 높이를 sin(t·π) 로 계산해(t는 0~1) 돌 블록 22개를 앞·뒤 립 두 겹으로
// lineTo 사각형으로 채운다. 상판·난간·기둥도 같은 좌표계에 얹어 z 내림차순(화가 알고리즘)으로
// 정렬해 그린다. 카메라는 포인터로 yaw·pitch 를 직접 회전시켜 돈다. 라이브러리·WebGL 없음.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var SEG = 22; // 아치 블록 개수
var SPAN = 200; // 아치 경간(스팬)
var ARCH_HALF_D = 15; // 아치 앞·뒤 립 사이 반깊이
var DECK_HALF_Z = 19; // 상판 반너비(z)
var DECK_MARGIN = 34; // 상판이 아치보다 더 뻗는 접근로 길이
var PLANK_N = 16; // 상판 널빤지 개수
var POST_N = 9; // 난간 기둥 개수
var RAIL_H = 22; // 난간 높이
var CART_MS = 2600; // 건너가는 카트의 왕복 주기
var LOOP_MS = 2700; // 미리보기 가상 포인터의 주기
// ────────────────────────────────────────────────────
var root = document.getElementById('abRoot');
var field = document.getElementById('abField');
var cv = document.getElementById('abCv');
var ctx = cv.getContext('2d');
var heightIn = document.getElementById('abHeight');
var heightOut = document.getElementById('abHeightVal');
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim(); }
var STONE = [tok('--ab-pink'), tok('--ab-mint'), tok('--ab-lilac'), tok('--ab-sky')]; // 돌 블록 순환
var CREAM = tok('--ab-cream');
var DECKC = tok('--ab-deck');
var RAIL = tok('--ab-rail');
var WATER = tok('--ab-water');
var INK = tok('--ab-ink');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var FOCAL = 300, BACK = 90;
var yaw = 0.55, pitch = 0.16;
var px01 = 0.5, py01 = 0.5; // 포인터 위치 비율(0~1) — 자동재생 중엔 가상 경로가 채운다
var manual = false;
var last = 0, raf = 0, t0 = -1;
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 카메라 — yaw(y축) 뒤 pitch(x축). 둘 다 포인터로 직접 회전한다
function camRot(p) {
var ca = Math.cos(yaw), sa = Math.sin(yaw);
var x1 = p.x * ca + p.z * sa, z1 = -p.x * sa + p.z * ca;
var cb = Math.cos(pitch), sb = Math.sin(pitch);
return { x: x1, y: p.y * cb - z1 * sb, z: p.y * sb + z1 * cb };
}
function project(p) { var s = FOCAL / (FOCAL + p.z + BACK); return { sx: W * 0.5 + p.x * s, sy: H * 0.62 - p.y * s, s: s }; }
// 아치 단면 — t(0~1) 를 사인 곡선으로 높이로 바꾼다. 경간 양끝에서 0, 정점에서 height
function archY(t, height) { return Math.sin(t * Math.PI) * height; }
function archX(t) { return (t - 0.5) * SPAN; }
function step(dt) {
yaw += (0.045 + (px01 - 0.5) * 0.75) * dt;
var aim = 0.16 + (py01 - 0.5) * 0.46;
pitch += (aim - pitch) * 0.08;
}
function quad(A, B, C, D, fill) {
ctx.beginPath();
ctx.moveTo(A.sx, A.sy);
ctx.lineTo(B.sx, B.sy);
ctx.lineTo(C.sx, C.sy);
ctx.lineTo(D.sx, D.sy);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
}
function draw(now) {
ctx.clearRect(0, 0, W, H);
var height = Number(heightIn.value);
var deckY = height + 14;
var scale = Math.min(W / 480, H / 300);
// 강물 — 배경 평면. 가로줄 몇 겹을 sin 물결로 흔들며 lineTo 로 잇는다(늘 가장 뒤에 그린다)
var WATER_HALF_Z = ARCH_HALF_D * 3.4;
for (var row = 0; row < 4; row++) {
var wz = -WATER_HALF_Z + row * (WATER_HALF_Z * 2 / 3);
ctx.beginPath();
var first = true;
for (var wx = -SPAN * 0.9; wx <= SPAN * 0.9; wx += 14) {
var wy = -10 - 3 * Math.sin(wx * 0.05 + now / 700 + row);
var pw = project(camRot({ x: wx * scale, y: wy * scale, z: wz * scale }));
if (first) { ctx.moveTo(pw.sx, pw.sy); first = false; } else { ctx.lineTo(pw.sx, pw.sy); }
}
ctx.strokeStyle = WATER;
ctx.lineWidth = 1.4;
ctx.globalAlpha = 0.5 - row * 0.08;
ctx.stroke();
ctx.globalAlpha = 1;
}
// ── 그릴 아이템을 모아 z 내림차순(먼 것 먼저) — 화가 알고리즘 ──
var items = [];
// 아치 블록 — 앞 립 z=+d, 뒤 립 z=-d 인 사각 블록을 lineTo 사각형으로 채운다
for (var i = 0; i < SEG; i++) {
(function (idx) {
var t0v = idx / SEG, t1v = (idx + 1) / SEG;
var y0 = archY(t0v, height), y1 = archY(t1v, height);
var x0 = archX(t0v), x1 = archX(t1v);
var w0 = camRot({ x: x0 * scale, y: y0 * scale, z: ARCH_HALF_D * scale });
var w1 = camRot({ x: x0 * scale, y: y0 * scale, z: -ARCH_HALF_D * scale });
var w2 = camRot({ x: x1 * scale, y: y1 * scale, z: -ARCH_HALF_D * scale });
var w3 = camRot({ x: x1 * scale, y: y1 * scale, z: ARCH_HALF_D * scale });
var zAvg = (w0.z + w1.z + w2.z + w3.z) / 4;
items.push({ z: zAvg, fn: function () {
quad(project(w0), project(w1), project(w2), project(w3), STONE[idx % STONE.length]);
}});
})(i);
}
// 교각(스팬드럴 기둥) — 아치 중앙부에서 상판까지 다섯 곳
[0.30, 0.42, 0.5, 0.58, 0.70].forEach(function (t) {
var y = archY(t, height);
var x = archX(t);
var wTop = camRot({ x: x * scale, y: deckY * scale, z: 0 });
var wBot = camRot({ x: x * scale, y: y * scale, z: 0 });
items.push({ z: (wTop.z + wBot.z) / 2, fn: function () {
var a = project(wTop), b = project(wBot);
ctx.strokeStyle = RAIL;
ctx.lineWidth = Math.max(1.4, 4 * a.s);
ctx.beginPath();
ctx.moveTo(a.sx, a.sy);
ctx.lineTo(b.sx, b.sy);
ctx.stroke();
}});
});
// 상판 — 널빤지 PLANK_N 개. z 는 거의 같아 순서 영향이 적지만 같은 방식으로 정렬한다
for (var p2 = 0; p2 < PLANK_N; p2++) {
(function (idx) {
var fx0 = -SPAN / 2 - DECK_MARGIN + (SPAN + DECK_MARGIN * 2) * (idx / PLANK_N);
var fx1 = -SPAN / 2 - DECK_MARGIN + (SPAN + DECK_MARGIN * 2) * ((idx + 1) / PLANK_N);
var w0 = camRot({ x: fx0 * scale, y: deckY * scale, z: -DECK_HALF_Z * scale });
var w1 = camRot({ x: fx1 * scale, y: deckY * scale, z: -DECK_HALF_Z * scale });
var w2 = camRot({ x: fx1 * scale, y: deckY * scale, z: DECK_HALF_Z * scale });
var w3 = camRot({ x: fx0 * scale, y: deckY * scale, z: DECK_HALF_Z * scale });
var zAvg = (w0.z + w1.z + w2.z + w3.z) / 4;
items.push({ z: zAvg + 1, fn: function () {
quad(project(w0), project(w1), project(w2), project(w3), idx % 2 ? DECKC : CREAM);
}});
})(p2);
}
// 난간 — 좌우 두 줄. 기둥을 세우고(lineTo) 꼭대기를 한 줄로 잇는다
[-1, 1].forEach(function (side) {
var z = side * DECK_HALF_Z;
var wRef = camRot({ x: 0, y: deckY * scale, z: z * scale });
items.push({ z: wRef.z - 0.5, fn: function () {
var tops = [];
for (var pIdx = 0; pIdx <= POST_N; pIdx++) {
var fx = -SPAN / 2 - DECK_MARGIN + (SPAN + DECK_MARGIN * 2) * (pIdx / POST_N);
var base = project(camRot({ x: fx * scale, y: deckY * scale, z: z * scale }));
var top = project(camRot({ x: fx * scale, y: (deckY + RAIL_H) * scale, z: z * scale }));
tops.push(top);
ctx.strokeStyle = RAIL;
ctx.lineWidth = Math.max(1, 2.4 * base.s);
ctx.beginPath();
ctx.moveTo(base.sx, base.sy);
ctx.lineTo(top.sx, top.sy);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(tops[0].sx, tops[0].sy);
for (var k = 1; k < tops.length; k++) ctx.lineTo(tops[k].sx, tops[k].sy);
ctx.lineWidth = Math.max(1.4, 3 * tops[0].s);
ctx.stroke();
}});
});
// 건너가는 카트 — 삼각파로 왕복(t2). 상판 위에 얹혀 다닌다
var ph2 = (now % CART_MS) / CART_MS;
var t2 = Math.abs(ph2 * 2 - 1); // 0→1→0 삼각파
var cx = -SPAN * 0.36 + SPAN * 0.72 * t2;
var wc = camRot({ x: cx * scale, y: (deckY + 5) * scale, z: 0 });
items.push({ z: wc.z - 1, fn: function () {
var pr = project(wc);
var rr = 6 * pr.s;
ctx.beginPath();
ctx.arc(pr.sx, pr.sy, rr, 0, TAU);
ctx.fillStyle = CREAM;
ctx.fill();
ctx.beginPath();
ctx.arc(pr.sx - rr * 0.3, pr.sy - rr * 0.3, rr * 0.32, 0, TAU);
ctx.fillStyle = STONE[0];
ctx.fill();
}});
items.sort(function (a, b) { return b.z - a.z; });
for (var q = 0; q < items.length; q++) items[q].fn();
}
function frame(now) {
if (t0 < 0) t0 = now;
var dt = last ? Math.min((now - last) / 1000, 0.05) : 0.016;
last = now;
if (!manual) {
// 미리보기 가상 포인터 — 시드 고정 Lissajous. 표 영역을 벗어난 자연스러운 경로
var ph = ((now - t0) % LOOP_MS) / LOOP_MS * TAU;
px01 = 0.5 + 0.38 * Math.sin(ph);
py01 = 0.5 + 0.32 * Math.sin(2 * ph + 1.05);
}
step(dt);
draw(now);
raf = requestAnimationFrame(frame);
}
function setHeight(v) { heightOut.textContent = v + 'px'; if (reduce) draw(t0 < 0 ? 0 : last); }
heightIn.addEventListener('input', function () { setHeight(heightIn.value); });
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { step(0); draw(last); }
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.08, 0], ArrowRight: [0.08, 0], ArrowUp: [0, -0.08], ArrowDown: [0, 0.08] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) { step(0); draw(last); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(last); });
fit();
if (reduce) {
// reduce — 루프 없이 다리가 놓인 한 장만 남는다(빈 화면이 아니다)
px01 = 0.58; py01 = 0.4;
step(0);
draw(0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// ArchBridge — vanilla 판(run/307/demo/09-scene3d-arch-bridge/index.html) 의 React 포트.
// 아치 단면 높이를 sin(t·π) 로 계산해(t는 0~1) 돌 블록 22개를 앞·뒤 립 두 겹으로
// lineTo 사각형으로 채운다. 상판·난간·기둥도 같은 좌표계에 얹어 z 내림차순(화가 알고리즘)으로
// 정렬해 그린다. 카메라는 포인터로 yaw·pitch 를 직접 회전시켜 돈다. 라이브러리·WebGL 없음.
// 값·수치는 vanilla 와 같다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ArchBridge.module.scss";
export type ArchBridgeProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type RngEvt = { target: { value: string } };
type PtrEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
// ── 변형 상스 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
const SEG = 22; // 아치 블록 개수
const SPAN = 200; // 아치 경간(스팬)
const ARCH_HALF_D = 15; // 아치 앞·뒤 립 사이 반깊이
const DECK_HALF_Z = 19; // 상판 반너비(z)
const DECK_MARGIN = 34; // 상판이 아치보다 더 뻗는 접근로 길이
const PLANK_N = 16; // 상판 널빤지 개수
const POST_N = 9; // 난간 기둥 개수
const RAIL_H = 22; // 난간 높이
const CART_MS = 2600; // 건너가는 카트의 왕복 주기
const LOOP_MS = 2700; // 미리보기 가상 포인터의 주기
const TAU = Math.PI * 2;
// ────────────────────────────────────────────────────
const HEIGHT_LABEL = "아치 높이";
const FIELD_LABEL = "아치 교량 원근 실험판";
const HINT = "아치가 사인 곡선으로 휘어 오릅니다 — 포인터 좌우로 시점이 돌고 상하로 각도가 바뀝니다";
const SPANDRELS = [0.30, 0.42, 0.5, 0.58, 0.70]; // 교량(스팬드럴 기둥) 위치 t
type P3 = { x: number; y: number; z: number };
type Proj = { sx: number; sy: number; s: number };
type Item = { z: number; fn: () => void };
const ARROWS: Record<string, [number, number]> = {
ArrowLeft: [-0.08, 0], ArrowRight: [0.08, 0], ArrowUp: [0, -0.08], ArrowDown: [0, 0.08]
};
export default function ArchBridge({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#6ec8ff", // vanilla $color: $subject-sky
}: ArchBridgeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const heightOutRef = useRef<HTMLElement | null>(null);
const heightValRef = useRef<number>(110) as Box<number>;
const stateRef = useRef({
W: 0, H: 0,
yaw: 0.55, pitch: 0.16,
px01: 0.5, py01: 0.5, // 포인터 위치 비율(0~1) — 자동재생 중엔 가상 경로가 채운다
manual: false,
last: 0, t0: -1
}) as Box<{ W: number; H: number; yaw: number; pitch: number; px01: number; py01: number; manual: boolean; last: number; t0: number }>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
stateRef.current.W = r.width;
stateRef.current.H = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 카메라 — yaw(y축) 뒤 pitch(x축). 둘 다 포인터로 직접 회전한다
function camRot(p: P3): P3 {
const st = stateRef.current;
const ca = Math.cos(st.yaw), sa = Math.sin(st.yaw);
const x1 = p.x * ca + p.z * sa, z1 = -p.x * sa + p.z * ca;
const cb = Math.cos(st.pitch), sb = Math.sin(st.pitch);
return { x: x1, y: p.y * cb - z1 * sb, z: p.y * sb + z1 * cb };
}
function project(p: P3): Proj {
const st = stateRef.current;
const s = 300 / (300 + p.z + 90); // FOCAL=300, BACK=90
return { sx: st.W * 0.5 + p.x * s, sy: st.H * 0.62 - p.y * s, s: s };
}
// 아치 단면 — t(0~1) 를 사인 곡선으로 높이로 바꾼다. 경간 양끝에서 0, 정점에서 height
function archY(t: number, height: number): number { return Math.sin(t * Math.PI) * height; }
function archX(t: number): number { return (t - 0.5) * SPAN; }
function step(dt: number): void {
const st = stateRef.current;
st.yaw += (0.045 + (st.px01 - 0.5) * 0.75) * dt;
const aim = 0.16 + (st.py01 - 0.5) * 0.46;
st.pitch += (aim - st.pitch) * 0.08;
}
function quad(A: Proj, B: Proj, C: Proj, D: Proj, fill: string): void {
const ctx = ctxRef.current;
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(A.sx, A.sy);
ctx.lineTo(B.sx, B.sy);
ctx.lineTo(C.sx, C.sy);
ctx.lineTo(D.sx, D.sy);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
}
function draw(now: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const st = stateRef.current;
if (!ctx || !root) return;
const W = st.W, H = st.H;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const STONE = [cssv("--ab-pink"), cssv("--ab-mint"), cssv("--ab-lilac"), cssv("--ab-sky")]; // 돌 블록 순환
const CREAM = cssv("--ab-cream");
const DECKC = cssv("--ab-deck");
const RAIL = cssv("--ab-rail");
const WATER = cssv("--ab-water");
ctx.clearRect(0, 0, W, H);
const height = heightValRef.current;
const deckY = height + 14;
const scale = Math.min(W / 480, H / 300);
// 강물 — 배경 평면. 가로줄 몇 겹을 sin 물결로 흔들며 lineTo 로 잇는다(늘 가장 뒤에 그린다)
const WATER_HALF_Z = ARCH_HALF_D * 3.4;
for (let row = 0; row < 4; row++) {
const wz = -WATER_HALF_Z + row * (WATER_HALF_Z * 2 / 3);
ctx.beginPath();
let first = true;
for (let wx = -SPAN * 0.9; wx <= SPAN * 0.9; wx += 14) {
const wy = -10 - 3 * Math.sin(wx * 0.05 + now / 700 + row);
const pw = project(camRot({ x: wx * scale, y: wy * scale, z: wz * scale }));
if (first) { ctx.moveTo(pw.sx, pw.sy); first = false; } else { ctx.lineTo(pw.sx, pw.sy); }
}
ctx.strokeStyle = WATER;
ctx.lineWidth = 1.4;
ctx.globalAlpha = 0.5 - row * 0.08;
ctx.stroke();
ctx.globalAlpha = 1;
}
// ── 그릴 아이템을 모아 z 내림차순(먼 것 먼저) — 화가 알고리즘 ──
const items: Item[] = [];
// 아치 블록 — 앞 립 z=+d, 뒤 립 z=-d 인 사각 블록을 lineTo 사각형으로 채운다
for (let i = 0; i < SEG; i++) {
const idx = i;
const t0v = idx / SEG, t1v = (idx + 1) / SEG;
const y0 = archY(t0v, height), y1 = archY(t1v, height);
const x0 = archX(t0v), x1 = archX(t1v);
const w0 = camRot({ x: x0 * scale, y: y0 * scale, z: ARCH_HALF_D * scale });
const w1 = camRot({ x: x0 * scale, y: y0 * scale, z: -ARCH_HALF_D * scale });
const w2 = camRot({ x: x1 * scale, y: y1 * scale, z: -ARCH_HALF_D * scale });
const w3 = camRot({ x: x1 * scale, y: y1 * scale, z: ARCH_HALF_D * scale });
const zAvg = (w0.z + w1.z + w2.z + w3.z) / 4;
items.push({ z: zAvg, fn: function () {
quad(project(w0), project(w1), project(w2), project(w3), STONE[idx % STONE.length]);
}});
}
// 교각(스팬드럴 기둥) — 아치 중앙부에서 상판까지 다섯 곳
for (const t of SPANDRELS) {
const y = archY(t, height);
const x = archX(t);
const wTop = camRot({ x: x * scale, y: deckY * scale, z: 0 });
const wBot = camRot({ x: x * scale, y: y * scale, z: 0 });
items.push({ z: (wTop.z + wBot.z) / 2, fn: function () {
const a = project(wTop), b = project(wBot);
ctx.strokeStyle = RAIL;
ctx.lineWidth = Math.max(1.4, 4 * a.s);
ctx.beginPath();
ctx.moveTo(a.sx, a.sy);
ctx.lineTo(b.sx, b.sy);
ctx.stroke();
}});
}
// 상판 — 널빤지 PLANK_N 개. z 는 거의 같아 순서 영향이 적지만 같은 방식으로 정렬한다
for (let p2 = 0; p2 < PLANK_N; p2++) {
const idx = p2;
const fx0 = -SPAN / 2 - DECK_MARGIN + (SPAN + DECK_MARGIN * 2) * (idx / PLANK_N);
const fx1 = -SPAN / 2 - DECK_MARGIN + (SPAN + DECK_MARGIN * 2) * ((idx + 1) / PLANK_N);
const w0 = camRot({ x: fx0 * scale, y: deckY * scale, z: -DECK_HALF_Z * scale });
const w1 = camRot({ x: fx1 * scale, y: deckY * scale, z: -DECK_HALF_Z * scale });
const w2 = camRot({ x: fx1 * scale, y: deckY * scale, z: DECK_HALF_Z * scale });
const w3 = camRot({ x: fx0 * scale, y: deckY * scale, z: DECK_HALF_Z * scale });
const zAvg = (w0.z + w1.z + w2.z + w3.z) / 4;
items.push({ z: zAvg + 1, fn: function () {
quad(project(w0), project(w1), project(w2), project(w3), idx % 2 ? DECKC : CREAM);
}});
}
// 난간 — 좌우 두 줄. 기둥을 세우고(lineTo) 꼭대기를 한 줄로 잇는다
for (const side of [-1, 1]) {
const z = side * DECK_HALF_Z;
const wRef = camRot({ x: 0, y: deckY * scale, z: z * scale });
items.push({ z: wRef.z - 0.5, fn: function () {
const tops: Proj[] = [];
for (let pIdx = 0; pIdx <= POST_N; pIdx++) {
const fx = -SPAN / 2 - DECK_MARGIN + (SPAN + DECK_MARGIN * 2) * (pIdx / POST_N);
const base = project(camRot({ x: fx * scale, y: deckY * scale, z: z * scale }));
const top = project(camRot({ x: fx * scale, y: (deckY + RAIL_H) * scale, z: z * scale }));
tops.push(top);
ctx.strokeStyle = RAIL;
ctx.lineWidth = Math.max(1, 2.4 * base.s);
ctx.beginPath();
ctx.moveTo(base.sx, base.sy);
ctx.lineTo(top.sx, top.sy);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(tops[0].sx, tops[0].sy);
for (let k = 1; k < tops.length; k++) ctx.lineTo(tops[k].sx, tops[k].sy);
ctx.lineWidth = Math.max(1.4, 3 * tops[0].s);
ctx.stroke();
}});
}
// 건너가는 카트 — 삼각파로 왕복(t2). 상판 위에 얹혀 다닌다
const ph2 = (now % CART_MS) / CART_MS;
const t2 = Math.abs(ph2 * 2 - 1); // 0→1→0 삼각파
const cx = -SPAN * 0.36 + SPAN * 0.72 * t2;
const wc = camRot({ x: cx * scale, y: (deckY + 5) * scale, z: 0 });
items.push({ z: wc.z - 1, fn: function () {
const pr = project(wc);
const rr = 6 * pr.s;
ctx.beginPath();
ctx.arc(pr.sx, pr.sy, rr, 0, TAU);
ctx.fillStyle = CREAM;
ctx.fill();
ctx.beginPath();
ctx.arc(pr.sx - rr * 0.3, pr.sy - rr * 0.3, rr * 0.32, 0, TAU);
ctx.fillStyle = STONE[0];
ctx.fill();
}});
items.sort(function (a, b) { return b.z - a.z; });
for (let q = 0; q < items.length; q++) items[q].fn();
}
function frame(now: number): void {
const st = stateRef.current;
if (st.t0 < 0) st.t0 = now;
const dt = st.last ? Math.min((now - st.last) / 1000, 0.05) : 0.016;
st.last = now;
if (!st.manual) {
// 미리보기 가상 포인터 — 시드 고정 Lissajous. 표 영역을 벗어난 자연스러운 경로
const ph = ((now - st.t0) % LOOP_MS) / LOOP_MS * TAU;
st.px01 = 0.5 + 0.38 * Math.sin(ph);
st.py01 = 0.5 + 0.32 * Math.sin(2 * ph + 1.05);
}
step(dt);
draw(now);
rafRef.current = requestAnimationFrame(frame);
}
function setHeight(v: string): void {
heightValRef.current = Number(v);
if (heightOutRef.current) heightOutRef.current.textContent = v + 'px';
if (reduced()) draw(stateRef.current.t0 < 0 ? 0 : stateRef.current.last);
}
function onRange(e: RngEvt): void {
setHeight(e.target.value);
}
function takeManual(): void {
const st = stateRef.current;
if (!st.manual) {
st.manual = true;
rootRef.current?.classList.remove("is-demo");
}
}
function onPointerMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const st = stateRef.current;
const r = field.getBoundingClientRect();
st.px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
st.py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
takeManual();
if (reduced()) { step(0); draw(st.last); }
}
function onPointerLeave(): void {
const st = stateRef.current;
if (st.manual) { st.px01 = 0.5; st.py01 = 0.5; }
}
// 키보드 경로 — 화살표로 시점을 움직인다(vanilla keydown 과 같은 값)
function onKeyDown(e: KeyEvt): void {
const d = ARROWS[e.key];
if (!d) return;
e.preventDefault();
takeManual();
const st = stateRef.current;
st.px01 = Math.max(0, Math.min(1, st.px01 + d[0]));
st.py01 = Math.max(0, Math.min(1, st.py01 + d[1]));
if (reduced()) { step(0); draw(st.last); }
}
function onResize(): void {
fit();
if (reduced()) draw(stateRef.current.last);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
const st = stateRef.current;
if (reduced()) {
// reduce — 루프 없이 다리가 놓인 한 장만 남는다(빈 화면이 아니다)
st.px01 = 0.58; st.py01 = 0.4;
step(0);
draw(0);
rootRef.current?.classList.add("is-still");
} else {
rafRef.current = requestAnimationFrame(frame);
}
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} ${styles.isDemo}`} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M20 180h216v20H20z"/><path d="M226 168h-14a86 86 0 0 0-168 0H30a8 8 0 0 0 0 16h196a8 8 0 0 0 0-16Zm-98-70a70.08 70.08 0 0 1 69.4 62H58.6A70.08 70.08 0 0 1 128 98Z"/></g></svg>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<label className={styles.dial}>{HEIGHT_LABEL}
<input type="range" min="60" max="160" step="10" defaultValue="110" aria-label={HEIGHT_LABEL} onChange={onRange}></input>
<b className={styles.val} ref={heightOutRef}>110px</b>
</label>
</div>
</div>
</main>
);
}09 アーチ橋シーン — マウス角度でなぞる橋 — 計測 累積面積 4.622% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css 3d アニメーション で作る マウス追従の立体シーン9選
このデモが属する記事
css 3d アニメーション で作る マウス追従の立体シーン9選css 3d アニメーション は、マウスの位置を角度に変えて観覧車や地形、橋のミニチュアを回す演出です。Three.jsなしで、キャンバス2D の上に座標計算だけで組み立てました。実測では05トーラス格子が累積面積29.176パーセント、0 この記事のデモをすべて見る


