06극좌표 로즈 리사주
직교좌표 대신 각도 θ 에 두 주파수 비율을 넣어 Math.cos 로 반지름을 구하는 극좌표 방식이라, 01 번과 같은 리사주 수식이라도 회전하는 꽃잎 모양으로 나옵니다. Math.sin 으로 다시 x·y 좌표로 바꿔 requestAnimationFrame 마다 찍습니다.
쓰임만다라형 로딩 화면, 장식용 배경 패턴
Math.cosMath.sinrequestAnimationFrame

<main class="stage">
<div class="pr" id="pr">
<p class="pr__hint">
<svg class="pr__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 32c-24 32-24 64 0 96c24-32 24-64 0-96Zm0 96c-32 24-64 24-96 0c32-24 64-24 96 0Zm0 0c32-24 64-24 96 0c-32 24-64 24-96 0Zm0 0c-24 32-24 64 0 96c24-32 24-64 0-96Z" opacity=".7"/><circle cx="128" cy="128" r="14"/></g></svg>
<span>같은 리사주 수식이 극좌표에서는 꽃잎 모양이 됩니다</span>
</p>
<div class="pr__field" id="field" role="application" aria-label="극좌표 로즈 실험판">
<canvas class="pr__cv" id="cv"></canvas>
<b class="pr__badge" aria-hidden="true"><span class="pr__badgeNum">5:4</span><span class="pr__badgeUnit">비율</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 극좌표 로즈 리사주 — 각도 θ 에 두 주파수 비율을 넣어 극좌표로 반지름을 구하는 방식.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
.pr {
--petal: #{$subject-pink};
--core: #{$subject-mint};
--badge-bg: #{rgba($subject-cream, .12)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.pr__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;
}
.pr__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-pink; }
.pr__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
}
.pr__cv { display: block; width: 100%; height: 100%; }
.pr__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) {
.pr__field { height: 124px; }
.pr__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pr.is-still .pr__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);
}
.pr {
--petal: #ff9ebb;
--core: #4cd4a6;
--badge-bg: rgba(255, 247, 230, 0.12);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.pr__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;
}
.pr__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff9ebb;
}
.pr__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
}
.pr__cv {
display: block;
width: 100%;
height: 100%;
}
.pr__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) {
.pr__field {
height: 124px;
}
.pr__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pr.is-still .pr__badge {
opacity: 0.92 !important;
}
}// 극좌표 로즈 리사주 — 01 번과 같은 리사주 수식(두 주파수의 사인 합)이지만
// 직교좌표 대신 각도 θ 에 주파수 비율을 넣어 Math.cos 로 반지름을 구하는 극좌표 방식이라
// 회전하는 꽃잎 모양으로 나온다. Math.sin 으로 다시 x·y 좌표로 바꿔 requestAnimationFrame 마다 찍는다.
var root = document.getElementById('pr');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PETAL = css.getPropertyValue('--petal').trim();
var CORE = css.getPropertyValue('--core').trim();
var K = 5 / 4; // 꽃잎 개수를 정하는 주파수 비율 n/d
var LOOP = 2; // 곡선이 회전 한 바퀴 도는 시간(초)
var TRAIL = 0.09;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 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);
}
// 로즈 곡선 위 한 점 — r = cos(K·θ) 를 반지름으로, 회전(spin)을 더해 전체를 돌린다
function petalPoint(th, spin, R) {
var r = Math.cos(K * th) * R;
var a = th + spin;
return {
x: W * 0.5 + Math.sin(a) * r,
y: H * 0.5 + Math.cos(a) * r
};
}
function draw(sec) {
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,' + TRAIL + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
var spin = (sec % LOOP) / LOOP * Math.PI * 2 * 0.5;
var R = Math.min(W, H) * 0.42;
var N = 480; // K 가 분수라 한 바퀴를 다 그리려면 표본이 많이 필요하다
ctx.strokeStyle = PETAL;
ctx.lineWidth = 1.6;
ctx.beginPath();
for (var i = 0; i <= N; i++) {
var p = petalPoint(i / N * Math.PI * 8, spin, R);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(W * 0.5, H * 0.5, 4, 0, Math.PI * 2);
ctx.fill();
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.4); });
if (reduce) {
// reduce — 루프 없이 회전 0.4의 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(0.4);
} else {
raf = requestAnimationFrame(frame);
}// PolarRoseLissajous — vanilla 판(run/303/demo/06-wave-polar-rose-lissajous/index.html) 의 React 포트.
// 극좌표 로즈 리사주 — 01 번과 같은 리사주 수식(두 주파수의 사인 합)이지만
// 직교좌표 대신 각도 θ 에 주파수 비율을 넣어 Math.cos 로 반지름을 구하는 극좌표 방식이라
// 회전하는 꽃잎 모양으로 나온다. Math.sin 으로 다시 x·y 좌표로 바꿔 requestAnimationFrame 마다 찍는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./PolarRoseLissajous.module.scss";
export type PolarRoseLissajousProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "같은 리사주 수식이 극좌표에서는 꽃잎 모양이 됩니다";
const FIELD_LABEL = "극좌표 로즈 실험판";
const BADGE_TEXT = "5:4";
const BADGE_UNIT = "비율";
const K = 5 / 4; // 꽃잎 개수를 정하는 주파수 비율 n/d
const LOOP = 2; // 곡선이 회전 한 바퀴 도는 시간(초)
const TRAIL = 0.09;
export default function PolarRoseLissajous(props: PolarRoseLissajousProps) {
const duration = props.duration ?? 2;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const accentColor = props.accentColor ?? "#ff9ebb";
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const vars: Vars = { "--duration": duration + "s", "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
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 petal = css.getPropertyValue("--petal").trim();
const core = css.getPropertyValue("--core").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 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);
}
// 로즈 곡선 위 한 점 — r = cos(K·θ) 를 반지름으로, 회전(spin)을 더해 전체를 돌린다
function petalPoint(th: number, spin: number, R: number) {
const r = Math.cos(K * th) * R;
const a = th + spin;
return {
x: W * 0.5 + Math.sin(a) * r,
y: H * 0.5 + Math.cos(a) * r,
};
}
function draw(sec: number) {
ctx!.globalCompositeOperation = "destination-out";
ctx!.fillStyle = "rgba(0,0,0," + TRAIL + ")";
ctx!.fillRect(0, 0, W, H);
ctx!.globalCompositeOperation = "source-over";
const spin = (sec % LOOP) / LOOP * Math.PI * 2 * 0.5;
const R = Math.min(W, H) * 0.42;
const N = 480; // K 가 분수라 한 바퀴를 다 그리려면 표본이 많이 필요하다
ctx!.strokeStyle = petal;
ctx!.lineWidth = 1.6;
ctx!.beginPath();
for (let i = 0; i <= N; i++) {
const p = petalPoint(i / N * Math.PI * 8, spin, R);
if (i === 0) ctx!.moveTo(p.x, p.y); else ctx!.lineTo(p.x, p.y);
}
ctx!.stroke();
ctx!.fillStyle = core;
ctx!.beginPath();
ctx!.arc(W * 0.5, H * 0.5, 4, 0, Math.PI * 2);
ctx!.fill();
}
function frame(now: number) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) draw(0.4);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 회전 0.4의 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
draw(0.4);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
root.classList.remove("is-still");
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={styles.pr} style={vars}>
<p className={styles["pr__hint"]}>
<svg className={styles["pr__ico"]} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 32c-24 32-24 64 0 96c24-32 24-64 0-96Zm0 96c-32 24-64 24-96 0c32-24 64-24 96 0Zm0 0c32-24 64-24 96 0c-32 24-64 24-96 0Zm0 0c-24 32-24 64 0 96c24-32 24-64 0-96Z" opacity=".7"/><circle cx="128" cy="128" r="14"/></g></svg>
<span>{HINT}</span>
</p>
<div ref={fieldRef} className={styles["pr__field"]} role="application" aria-label={FIELD_LABEL}>
<canvas ref={cvRef} className={styles["pr__cv"]}></canvas>
<b className={styles["pr__badge"]} aria-hidden="true"><span className={styles["pr__badgeNum"]}>{BADGE_TEXT}</span><span className={styles["pr__badgeUnit"]}>{BADGE_UNIT}</span></b>
</div>
</div>
</main>
);
}06 극좌표 로즈 리사주 — 측정 누적면적 17.09% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 리사주 곡선 만들기 9종 — 파동 간섭으로 그리는 캔버스
이 데모가 속한 글
리사주 곡선 만들기 9종 — 파동 간섭으로 그리는 캔버스리사주 곡선은 서로 다른 주파수의 두 사인파를 X·Y 축에 각각 걸었을 때 펜이 그리는 폐곡선으로, 오실로스코프 화면에서 위상차를 눈으로 확인하는 데 쓰이는 그림입니다. 그 원리를 Math.sin 과 캔버스만으로 아 이 글의 데모 전부 보기


