01リサージュ XY オシロスコープ
2つのサイン波をX軸とY軸にそれぞれ与え、位相差の分だけ回転する閉曲線を描きます。requestAnimationFrame ごとに Math.sin で座標を計算して点を打ち、globalCompositeOperation で直前のフレームを薄く消して残像だけ残します。
使いどころオシロスコープのデモ画面、位相差の教材
requestAnimationFrameMath.singlobalCompositeOperation

<main class="stage">
<div class="ls" id="ls">
<p class="ls__hint">
<svg class="ls__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 40H40a24 24 0 0 0-24 24v128a24 24 0 0 0 24 24h176a24 24 0 0 0 24-24V64a24 24 0 0 0-24-24ZM40 56h176a8 8 0 0 1 8 8v24H32V64a8 8 0 0 1 8-8Zm176 144H40a8 8 0 0 1-8-8V104h192v88a8 8 0 0 1-8 8Z"/><circle cx="56" cy="72" r="8"/><circle cx="84" cy="72" r="8"/><path d="M128 120a40 40 0 1 0 40 40a40 40 0 0 0-40-40Zm0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24Z"/></g></svg>
<span>2つのサイン波は3:2の周波数比 — 曲線が生きて動きます</span>
</p>
<div class="ls__field" id="field" role="application" aria-label="リサージュ曲線の実験盤">
<canvas class="ls__cv" id="cv"></canvas>
<b class="ls__badge" aria-hidden="true"><span class="ls__badgeNum">3:2</span><span class="ls__badgeUnit">周波数比</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 리사주 XY 오실로스코프 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 실시간으로 프레임을 찍어
// 캔버스가 실제로 도는 모습이 담긴다(도구/demo.py 렌더(), 077 선례).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ls {
--scope-bg: #{$stage-ink};
--trace: #{$subject-mint};
--grid: #{rgba($subject-cream, .14)};
--badge-bg: #{rgba($subject-cream, .12)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__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-cream;
}
.ls__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-mint; }
.ls__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: var(--scope-bg);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
}
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.ls__field { height: 124px; }
.ls__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__badge { opacity: .92 !important; }
}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);
}
.ls {
--scope-bg: #17141a;
--trace: #4cd4a6;
--grid: rgba(255, 247, 230, 0.14);
--badge-bg: rgba(255, 247, 230, 0.12);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__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: #fff7e6;
}
.ls__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #4cd4a6;
}
.ls__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: var(--scope-bg);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.ls__field {
height: 124px;
}
.ls__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__badge {
opacity: 0.92 !important;
}
}// 리사주 XY 오실로스코프 — X 축·Y 축에 각기 다른 주파수의 사인파를 걸면 펜이 그리는 자취가
// 리사주 곡선이 된다. 매 프레임 Math.sin 으로 좌표를 계산해 점을 찍고,
// globalCompositeOperation(destination-out) 으로 이전 프레임을 살짝 지워 잔상 트레일을 남긴다.
var root = document.getElementById('ls');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
// willReadFrequently 를 안 쓰는 대신 매 프레임 전체를 다시 칠하는 방식(잔상은 destination-out)으로
// getImageData 왕복 없이 돈다 — 픽셀 계산은 02·04·07 번이 맡는다.
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var TRACE = css.getPropertyValue('--trace').trim();
var GRID = css.getPropertyValue('--grid').trim();
var BGN = css.getPropertyValue('--scope-bg').trim();
var FX = 3, FY = 2; // X·Y 주파수 비 — 3:2 로 두면 곡선이 닫히는 데 2 초가 걸린다
var LOOP = 2; // 곡선 한 바퀴(초). 캡처 창(2초)과 같아 gif 가 이어진다
var TRAIL = 0.10; // 매 프레임 지우는 양(알파) — 작게 두면 잔상이 길게 늘어진다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var phase = 0; // 슬라이더·자동 루프가 공유하는 위상(라디안)
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);
}
// 눈금 — 오실로스코프 화면의 중앙 십자·가는 격자. 곡선이 어디를 지나는지의 기준이 된다
function grid() {
ctx.strokeStyle = GRID;
ctx.lineWidth = 1;
ctx.beginPath();
var step = 24;
for (var x = step; x < W; x += step) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (var y = step; y < H; y += step) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
ctx.strokeStyle = GRID;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(W * 0.5, 0); ctx.lineTo(W * 0.5, H);
ctx.moveTo(0, H * 0.5); ctx.lineTo(W, H * 0.5);
ctx.stroke();
}
// 리사주 점 하나 — X 는 sin(FX·θ), Y 는 sin(FY·θ+phase). 펜이 이 점을 계속 따라다닌다
function point(th) {
var a = Math.min(W, H) * 0.36;
return {
x: W * 0.5 + Math.sin(th * FX) * a,
y: H * 0.5 + Math.sin(th * FY + phase) * a
};
}
function drawTrail(sec) {
// 잔상 — destination-out 으로 살짝 지운 뒤 격자 위에 자취를 다시 그린다.
// 곡선 전체(θ 0→2π)를 얇게 깔고 펜 머리만 진하게 밝힌다
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,' + TRAIL + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
grid();
var N = 160; // 곡선 조각 수 — 부드러우면 충분하다
ctx.strokeStyle = TRACE;
ctx.lineWidth = 1.4;
ctx.globalAlpha = 0.5;
ctx.beginPath();
for (var i = 0; i <= N; i++) {
var p = point(i / N * Math.PI * 2);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.globalAlpha = 1;
// 펜 머리 — 자초 위를 도는 밝은 점. sec 를 그대로 위상으로 쓴다(결정적)
var t = (sec % LOOP) / LOOP * Math.PI * 2;
var hp = point(t);
ctx.fillStyle = TRACE;
ctx.beginPath();
ctx.arc(hp.x, hp.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.beginPath();
ctx.arc(hp.x, hp.y, 2, 0, Math.PI * 2);
ctx.fill();
}
// 위상을 천천히 밀어 곡선 모양이 살아 움직이게 한다 — 같은 3:2 여도 위상이 바뀌면 기울어진다
function frame(now) {
var sec = now / 1000;
phase = sec / LOOP * Math.PI * 0.5;
drawTrail(sec);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) drawTrail(0.3); });
if (reduce) {
// reduce — 루프를 돌리지 않고 위상 0.3의 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
phase = 0.3;
drawTrail(0.3);
} else {
raf = requestAnimationFrame(frame);
}// LissajousXyScope — vanilla 판(run/303/demo/01-wave-lissajous-xy-scope/index.html) 의 React 포트.
// X 축·Y 축에 각기 다른 주파수의 사인파를 걸어 펜이 그리는 리사주 곡선을
// 캔버스 rAF 루프(Math.sin + destination-out 잔상)로 그린다. CSS 애니메이션 0개.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LissajousXyScope.module.scss";
export type LissajousXyScopeProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TITLE = "리사주 XY 오실로스코프 데모";
const HINT = "두 사인파의 주파수 비 3:2 — 곡선이 살아 움직입니다";
const FIELD_LABEL = "리사주 곡선 실험판";
const BADGE_NUM = "3:2";
const BADGE_UNIT = "주파수비";
const FX = 3; // X·Y 주파수 비 — 3:2 로 두면 곡선이 닫히는 데 2 초가 걸린다
const LOOP = 2; // 곡선 한 바퀴(초)
const TRAIL = 0.10; // 매 프레임 지우는 양(알파) — 작게 두면 잔상이 길게 늘어진다
export default function LissajousXyScope({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#4cd4a6",
}: LissajousXyScopeProps) {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
// willReadFrequently 를 안 쓰는 대신 매 프레임 전체를 다시 칠하는 방식(잔상은 destination-out)으로
// getImageData 왕복 없이 돈다.
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const TRACE = css.getPropertyValue("--trace").trim();
const GRID = css.getPropertyValue("--grid").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
let phase = 0; // 자동 루프가 밀어가는 위상(라디안)
function fit() {
const 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);
}
// 눈금 — 오실로스코프 화면의 중앙 십자·가는 격자. 곡선이 어디를 지나는지의 기준이 된다
function grid() {
ctx.strokeStyle = GRID;
ctx.lineWidth = 1;
ctx.beginPath();
const step = 24;
for (let x = step; x < W; x += step) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (let y = step; y < H; y += step) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
ctx.strokeStyle = GRID;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(W * 0.5, 0); ctx.lineTo(W * 0.5, H);
ctx.moveTo(0, H * 0.5); ctx.lineTo(W, H * 0.5);
ctx.stroke();
}
// 리사주 점 하나 — X 는 sin(FX·θ), Y 는 sin(FY·θ+phase). 펜이 이 점을 계속 따라다닌다
function point(th: number) {
const a = Math.min(W, H) * 0.36;
return {
x: W * 0.5 + Math.sin(th * FX) * a,
y: H * 0.5 + Math.sin(th * 2 + phase) * a,
};
}
function drawTrail(sec: number) {
// 잔상 — destination-out 으로 살짝 지운 뒤 격자 위에 자취를 다시 그린다.
// 곡선 전체(θ 0→2π)를 얇게 깔고 펜 머리만 진하게 밝힌다
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0,0,0," + TRAIL + ")";
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "source-over";
grid();
const N = 160; // 곡선 조각 수 — 부드러우면 충분하다
ctx.strokeStyle = TRACE;
ctx.lineWidth = 1.4;
ctx.globalAlpha = 0.5;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const p = point((i / N) * Math.PI * 2);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.globalAlpha = 1;
// 펜 머리 — 자취 위를 도는 밝은 점. sec 를 그대로 위상으로 쓴다(결정적)
const t = ((sec % LOOP) / LOOP) * Math.PI * 2;
const hp = point(t);
ctx.fillStyle = TRACE;
ctx.beginPath();
ctx.arc(hp.x, hp.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.beginPath();
ctx.arc(hp.x, hp.y, 2, 0, Math.PI * 2);
ctx.fill();
}
// 위상을 천천히 밀어 곡선 모양이 살아 움직이게 한다 — 같은 3:2 여도 위상이 바뀌면 기울어진다
function frame(now: number) {
const sec = now / 1000;
phase = (sec / LOOP) * Math.PI * 0.5;
drawTrail(sec);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) drawTrail(0.3);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프를 돌리지 않고 위상 0.3의 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
phase = 0.3;
drawTrail(0.3);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ls} style={vars} ref={rootRef}>
<p className={styles["ls__hint"]}>
<svg className={styles["ls__ico"]} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="M216 40H40a24 24 0 0 0-24 24v128a24 24 0 0 0 24 24h176a24 24 0 0 0 24-24V64a24 24 0 0 0-24-24ZM40 56h176a8 8 0 0 1 8 8v24H32V64a8 8 0 0 1 8-8Zm176 144H40a8 8 0 0 1-8-8V104h192v88a8 8 0 0 1-8 8Z" />
<circle cx="56" cy="72" r="8" />
<circle cx="84" cy="72" r="8" />
<path d="M128 120a40 40 0 1 0 40 40a40 40 0 0 0-40-40Zm0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24Z" />
</g>
</svg>
<span>{HINT}</span>
</p>
<div className={styles["ls__field"]} role="application" aria-label={FIELD_LABEL} ref={fieldRef}>
<canvas className={styles["ls__cv"]} ref={cvRef} aria-label={TITLE} />
<b className={styles["ls__badge"]} aria-hidden="true">
<span className={styles["ls__badgeNum"]}>{BADGE_NUM}</span>
<span className={styles["ls__badgeUnit"]}>{BADGE_UNIT}</span>
</b>
</div>
</div>
</main>
);
}01 リサージュ XY オシロスコープ — 計測 累積面積 16.829% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · リサージュ曲線 作り方9選 — 波の干渉で描くキャンバス
このデモが属する記事
リサージュ曲線 作り方9選 — 波の干渉で描くキャンバスリサージュ曲線とは、周波数の異なる二つのサイン波をX軸とY軸にそれぞれ与えたときにペンが描く閉曲線で、オシロスコープの画面で位相差を目で確かめるために使われる図形です。ここではその原理をMath.sinとキャンバスだけで九つの場面に広げ、す この記事のデモをすべて見る


