08레이더 그래프 변형
여섯 역량의 다각형이 팀 A 값에서 팀 B 값으로 정점별로 속도를 달리해 모핑한다. 채움은 반투명 두 장이 겹쳐 교차 영역이 진해진다.
쓰임역량 비교·두 후보 대조
polygoneaseOutCubicglobalAlpha

<main class="stage">
<div class="rm" id="rm">
<p class="rm__hint">
<svg class="rm__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 0 0-90.08 155.86l12.47 37.39a8 8 0 0 0 10.12 10.12l37.39-12.47A104 104 0 1 0 128 24m0 136a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M176 128a48 48 0 1 1-48-48a48 48 0 0 1 48 48"/></g></svg>
<span>여섯 축 다각형이 정점마다 다른 속도로 모핑합니다 — <code>lerp</code>·<code>globalAlpha</code>·<code>정점 시차</code></span>
</p>
<div class="rm__field" id="field" role="application" aria-label="레이더 차트 모핑 실험판" tabindex="0">
<canvas class="rm__cv" id="cv" role="img" aria-label="여섯 축 역량 레이더 차트 — A 팀 다각형이 B 팀 다각형으로 정점마다 제각각 속도로 바뀌며 두 반투명 면이 겹친다"></canvas>
</div>
<div class="rm__bar">
<button class="rm__btn" id="replay" type="button">
<svg class="rm__bico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
<span>다시 그리기</span>
</button>
<table class="sr-only">
<caption>여섯 축 역량 점수(백점 만점) — A 팀과 B 팀</caption>
<thead><tr><th scope="col">축</th><th scope="col">A 팀</th><th scope="col">B 팀</th></tr></thead>
<tbody>
<tr><th scope="row">속도</th><td>72</td><td>58</td></tr>
<tr><th scope="row">정확도</th><td>64</td><td>82</td></tr>
<tr><th scope="row">안정성</th><td>80</td><td>90</td></tr>
<tr><th scope="row">탐색</th><td>52</td><td>74</td></tr>
<tr><th scope="row">협업</th><td>76</td><td>60</td></tr>
<tr><th scope="row">지속</th><td>58</td><td>78</td></tr>
</tbody>
</table>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 레이더 차트 모핑 — 움직임은 전부 캔버스 rAF 루프 안(정점별 시차 모핑 + 반투명 두 층 겹침).
// CSS 애니메이션 0개 — 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 두 계열은 차트 토큰 1번(A 팀)과 3번(B 팀). 무대는 $stage-paper(흰).
$duration: $dur-loop;
$easing: $ease-out; // easeOutCubic 을 JS 에서 계산하므로 CSS 쪽 이징 토큰은 감속 역할명으로
$color: $chart-1;
@include stage($stage-paper);
// 화면 낭독기용 표 — 캔버스를 읽지 못하므로 같은 데이터를 표로 붙인다
.sr-only {
// fixed + top/left 명시 — absolute 였을 때 표의 실제 콘텐츠 높이(행 수만큼 ≒ 390px)가
// scrollHeight 에 잡혀 데스크톱·폰 전부 세로 넘침(BD8/BD9)이 났다. top 을 주지 않으면
// static 자리 기준으로 배치돼 여전히 scrollHeight 에 들어간다 — 좌상단 고정으로 뺀다.
position: fixed; top: 0; left: 0; width: 1px; height: 1px; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.rm {
// 캔버스가 읽는 색 — JS 가 getComputedStyle 로 꺼내 쓴다. vivid 리터럴은 두지 않는다(BD26)
--chart-1: #{$chart-1};
--chart-3: #{$chart-3};
--ink: #{$subject-ink};
--mute: #{rgba($subject-ink, .62)};
--grid: #{rgba($subject-ink, .10)};
--paper: #{$stage-paper};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rm__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;
}
.rm__hint code {
padding: 0 $sp-1; border-radius: $r-xs;
background: rgba($chart-1, .10);
color: $subject-ink;
font-weight: 700; font-size: .92em; line-height: 1.3;
}
.rm__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.rm__field {
position: relative; height: 176px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.rm__cv { display: block; width: 100%; height: 100%; }
.rm__bar {
display: flex; align-items: center; gap: $sp-2;
}
.rm__btn {
display: inline-flex; align-items: center; gap: $sp-1;
box-sizing: border-box; padding: $sp-2 $sp-4; border: 0;
border-radius: $r-pill; background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 12px; line-height: 1.2;
cursor: pointer;
}
.rm__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rm__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.rm { gap: $sp-1; }
.rm__field { height: 112px; }
.rm__hint { font-size: 10px; }
.rm__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끌 애니메이션이 없다. 완성 상태 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.rm__btn { cursor: default; }
}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);
}
.sr-only {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.rm {
--chart-1: #2f6df6;
--chart-3: #2b9d74;
--ink: #17141a;
--mute: rgba(23, 20, 26, 0.62);
--grid: rgba(23, 20, 26, 0.1);
--paper: #ffffff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rm__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;
}
.rm__hint code {
padding: 0 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.1);
color: #17141a;
font-weight: 700;
font-size: 0.92em;
line-height: 1.3;
}
.rm__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.rm__field {
position: relative;
height: 176px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.rm__cv {
display: block;
width: 100%;
height: 100%;
}
.rm__bar {
display: flex;
align-items: center;
gap: 8px;
}
.rm__btn {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.2;
cursor: pointer;
}
.rm__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rm__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.rm {
gap: 4px;
}
.rm__field {
height: 112px;
}
.rm__hint {
font-size: 10px;
}
.rm__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__btn {
cursor: default;
}
}// 레이더 차트 모핑 — 6축 다각형이 팀 A 값에서 팀 B 값으로 정점마다 서로 다른 속도로 바뀐다.
// 정점별 속도차는 시작 시각(offset)과 지속 배율(rate) 두 개로 만든다 — 같은 시각에 출발해도
// 배율이 다르면 도착이 어긋나고, 다각형이 찌그러졌다가 제 모양으로 펴지는 모핑의 핵이 그 어긋남이다.
// 두 팀 면은 반투명 fill(알파 0.25)로 겹친다 — source-over 겹침으로 교차 영역이 자연히 진해진다.
// 2초 루프: 0~0.08 축 라벨 등장, 0.08~0.62 A→B 모핑, 0.62~0.75 범례 칩 강조, 0.75~0.90 복귀.
var root = document.getElementById('rm');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var replayBtn = document.getElementById('replay');
var css = getComputedStyle(root);
var C_A = css.getPropertyValue('--chart-1').trim();
var C_B = css.getPropertyValue('--chart-3').trim();
var C_INK = css.getPropertyValue('--ink').trim();
var C_MUTE = css.getPropertyValue('--mute').trim();
var C_GRID = css.getPropertyValue('--grid').trim();
var C_PAPER = css.getPropertyValue('--paper').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 고정 시드 — 여섯 축 역량 점수 두 세트(백점 만점)
var AXES = ['속도', '정확도', '안정성', '탐색', '협업', '지속'];
var A_VAL = [88, 82, 92, 78, 90, 84];
var B_VAL = [34, 26, 40, 22, 30, 28];
// 정점별 모핑 속도 — 시작 시차(offset)와 지속 배율(rate). 여섯 정점이 제각각 어긋난다
var VTX = [
{ off: 0.00, rate: 1.25 },
{ off: 0.04, rate: 0.80 },
{ off: 0.02, rate: 1.10 },
{ off: 0.06, rate: 0.70 },
{ off: 0.01, rate: 1.35 },
{ off: 0.05, rate: 0.90 }
];
var LOOP = 2; // 한 사이클 2초
var MORPH0 = 0.08; // 모핑 시작 — 그 전까지 축 라벨이 뜬다
var MORPH1 = 0.62; // 모핑 끝 — 이 뒤 범례 칩이 강조된다
var BACK1 = 0.90; // 복귀 완료 — 루프 끝(90%) 안에서 A 로 되돌아간다
var N = AXES.length;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var t0 = 0, 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);
}
// 감속 이징 — 정점이 목표에 미끄러지듯 닿는다
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 축 i 방향의 단위 벡터 — 12시부터 시계 방향
function angleAt(i) { return -Math.PI / 2 + (Math.PI * 2 * i) / N; }
// 정점 i 의 모핑 진행(0..1). p 는 루프 전체 진행, span 은 이 구간의 전체 길이(정규화).
// VTX[i].off 만큼 늦게 출발하고 VTX[i].rate 배로 빨리/느리게 끝난다 — 정점마다 어긋난다.
function vertexP(i, p, span, start) {
var local = (p - start - VTX[i].off) / (span / VTX[i].rate);
return Math.max(0, Math.min(1, local));
}
// 반경 — 값(0..100)을 최대 반경에 얹는다
function rAt(v) { return R * Math.max(0.08, v / 100); }
var R = 0;
// 다각형 경로 — 값열을 정점 좌표로 잇는다
function polyPath(vals) {
ctx.beginPath();
for (var i = 0; i < N; i++) {
var a = angleAt(i);
var x = CX + Math.cos(a) * rAt(vals[i]);
var y = CY + Math.sin(a) * rAt(vals[i]);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
}
var CX = 0, CY = 0;
// 한 팀 층 — 그릴 값열과 계열색. 면(알파 0.25) + 테두리 + 정점 점
function drawLayer(vals, color, alpha) {
polyPath(vals);
ctx.globalAlpha = 0.32 * alpha;
ctx.fillStyle = color;
ctx.fill();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.stroke();
for (var i = 0; i < N; i++) {
var a = angleAt(i);
ctx.beginPath();
ctx.arc(CX + Math.cos(a) * rAt(vals[i]), CY + Math.sin(a) * rAt(vals[i]), 3.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
ctx.globalAlpha = 1;
}
function draw(p) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
// 중심·반경 — 필드 안쪽. 왼쪽에 범례 침을 둘 자리를 남긴다
var legendW = 76;
CX = (W - legendW) / 2 + 6;
CY = H / 2 + 6;
R = Math.min(W - legendW - 24, H - 44) / 2;
var axisP = Math.min(1, p / 0.08); // 축 라벨 등장(첫 8%)
// 거미줄 그리드 — 25/50/75/100 농도 고리 + 축 살
ctx.lineWidth = 1;
for (var ring = 25; ring <= 100; ring += 25) {
polyGrid(ring);
ctx.strokeStyle = C_GRID;
ctx.stroke();
}
for (var i = 0; i < N; i++) {
var a = angleAt(i);
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(a) * R, CY + Math.sin(a) * R);
ctx.strokeStyle = C_GRID;
ctx.stroke();
}
// 축 라벨 — 농도 고리가 뜬 뒤 반경 밖에 붙는다
ctx.font = '700 10px ' + FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (var l = 0; l < N; l++) {
var la = angleAt(l);
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText(AXES[l], CX + Math.cos(la) * (R + 14), CY + Math.sin(la) * (R + 12));
ctx.globalAlpha = 1;
}
// 모핑 값열 — A 팀은 고정, B 팀은 정점별 진행으로 A→B 사이를 간다
var morphing = A_VAL.slice();
var back = Math.max(0, (p - 0.75) / (BACK1 - 0.75)); // 0.75~0.90 복귀
for (var m = 0; m < N; m++) {
var fwd = easeOutCubic(vertexP(m, p, MORPH1 - MORPH0, MORPH0));
var v = A_VAL[m] + (B_VAL[m] - A_VAL[m]) * fwd;
// 복귀 — B 에 도달한 뒤 다시 A 로. 복귀도 정점 배율을 그대로 쓴다
var bk = easeOutCubic(Math.max(0, (back - VTX[m].off * 0.5) * VTX[m].rate));
morphing[m] = v + (A_VAL[m] - v) * Math.min(1, bk);
}
// 두 층 — A 팀(고정, 뒤) 과 모핑 층(앞). 반투명 면이 겹쳐 교차 영역이 진해진다
drawLayer(A_VAL, C_A, axisP);
drawLayer(morphing, C_B, Math.min(1, axisP * 1.2));
// 범례 칩 — 오른쪽 세로. 모핑이 끝난 뒤(0.62~0.75) 강조로 커진다
var hi = Math.max(0, Math.min(1, (p - MORPH1) / 0.13));
drawChip(C_A, 'A 팀', H / 2 - 26, axisP * 0.9, hi * 0.6);
drawChip(C_B, 'B 팀', H / 2 + 10, Math.min(1, axisP * 1.2), 0.6 + hi * 0.4);
// 평균 라벨 — 모핑 층의 현재 평균. 모핑이 끝나면 뜬다
if (p > MORPH1) {
var sum = 0;
for (var s = 0; s < N; s++) sum += morphing[s];
ctx.font = '700 11px ' + FONT;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = C_INK;
ctx.fillText('평균 ' + Math.round(sum / N), CX - R, CY - R - 8);
}
}
// 농도 고리 경로 — 값 ring(0..100) 반경의 육각형
function polyGrid(ring) {
ctx.beginPath();
for (var i = 0; i < N; i++) {
var a = angleAt(i);
var r = R * ring / 100;
var x = CX + Math.cos(a) * r;
var y = CY + Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
}
// 범례 칩 — 오른쪽 세로 배치. 색점 + 팀명. 강조(hi)로 색점이 커진다
function drawChip(color, label, y, alpha, hi) {
var x = W - 68;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(x, y + 8, 4 + hi * 2.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.font = '700 11px ' + FONT;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = C_INK;
ctx.fillText(label, x + 12, y + 8);
ctx.globalAlpha = 1;
}
function frame(now) {
if (!t0) t0 = now;
var p = ((now - t0) / 1000 % LOOP) / LOOP; // 0..1, 2초 루프
draw(p);
raf = requestAnimationFrame(frame);
}
replayBtn.addEventListener('click', function () { t0 = 0; });
window.addEventListener('resize', function () { fit(); if (reduce) draw(1); });
fit();
if (reduce) {
// reduce — 루프 없이 완성 상태(두 다각형이 겹친 B 팀 도달 장면) 한 장만 그린다
draw(0.75);
} else {
raf = requestAnimationFrame(frame);
}// RadarMorphChart — vanilla 판(run/305/demo/08-chart-canvas-radar-morph/index.html) 의 React 포트.
// 6축 다각형이 팀 A 값에서 팀 B 값으로 정점마다 서로 다른 속도(시차+배율)로 모핑하고
// 두 반투명 fill(알파 0.25)이 겹쳐 교차 영역이 진해진다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RadarMorphChart.module.scss";
export type RadarMorphChartProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// 고정 시드 — 여섯 축 역량 점수 두 세트(백점 만점)와 정점별 모핑 속도(시차·배율)
const AXES = ["속도", "정확도", "안정성", "탐색", "협업", "지속"];
const A_VAL = [72, 64, 80, 52, 76, 58];
const B_VAL = [58, 82, 90, 74, 60, 78];
const VTX = [
{ off: 0.00, rate: 1.25 },
{ off: 0.04, rate: 0.80 },
{ off: 0.02, rate: 1.10 },
{ off: 0.06, rate: 0.70 },
{ off: 0.01, rate: 1.35 },
{ off: 0.05, rate: 0.90 }
];
const LOOP = 2; // 한 사이클 2초
const MORPH0 = 0.08; // 모핑 시작 — 그 전까지 축 라벨이 뜬다
const MORPH1 = 0.62; // 모핑 끝 — 이 뒤 범례 칩이 강조된다
const BACK1 = 0.90; // 복귀 완료 — 루프 끝(90%) 안에서 A 로 되돌아간다
const N = AXES.length;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "레이더 차트 모핑 실험판";
const CV_LABEL = "여섯 축 역량 레이더 차트 — A 팀 다각형이 B 팀 다각형으로 정점마다 제각각 속도로 바뀌며 두 반투명 면이 겹친다";
export default function RadarMorphChart({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#2f6df6", // vanilla $color: $chart-1
}: RadarMorphChartProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const t0Ref = useRef(0) as Box<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 cssv(n: string): string {
const root = rootRef.current;
return root ? getComputedStyle(root).getPropertyValue(n).trim() : "";
}
// 고DPI — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
function fit(): void {
const cv = cvRef.current;
const s = sizeRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
s.w = r.width; s.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 감속 이징 — 정점이 목표에 미끄러지듯 닿는다
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
// 축 i 방향의 각도 — 12시부터 시계 방향
function angleAt(i: number): number {
return -Math.PI / 2 + (Math.PI * 2 * i) / N;
}
// 정점 i 의 모핑 진행(0..1). 시차(off)만큼 늦게 출발하고 배율(rate)로 빨리/느리게 끝난다
function vertexP(i: number, p: number, span: number, start: number): number {
const local = (p - start - VTX[i].off) / (span / VTX[i].rate);
return Math.max(0, Math.min(1, local));
}
// 다각형 경로 — 값열을 정점 좌표로 잇는다
function polyPath(cx: number, cy: number, R: number, vals: number[]): void {
const ctx = ctxRef.current!;
ctx.beginPath();
for (let i = 0; i < N; i++) {
const a = angleAt(i);
const r = R * Math.max(0.08, vals[i] / 100);
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
}
// 농도 고리 경로 — 값 ring(0..100) 반경의 육각형
function polyGrid(cx: number, cy: number, R: number, ring: number): void {
const ctx = ctxRef.current!;
ctx.beginPath();
for (let i = 0; i < N; i++) {
const a = angleAt(i);
const r = R * ring / 100;
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
}
// 한 팀 층 — 면(알파 0.25) + 테두리 + 정점 점. 두 층이 겹치면 교차 영역이 진해진다
function drawLayer(cx: number, cy: number, R: number, vals: number[], colorStr: string, alpha: number): void {
const ctx = ctxRef.current!;
polyPath(cx, cy, R, vals);
ctx.globalAlpha = 0.25 * alpha;
ctx.fillStyle = colorStr;
ctx.fill();
ctx.globalAlpha = alpha;
ctx.strokeStyle = colorStr;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.stroke();
for (let i = 0; i < N; i++) {
const a = angleAt(i);
ctx.beginPath();
ctx.arc(cx + Math.cos(a) * R * Math.max(0.08, vals[i] / 100), cy + Math.sin(a) * R * Math.max(0.08, vals[i] / 100), 2.4, 0, Math.PI * 2);
ctx.fillStyle = colorStr;
ctx.fill();
}
ctx.globalAlpha = 1;
}
// 범례 칩 — 오른쪽 세로 배치. 색점 + 팀명. 강조(hi)로 색점이 커진다
function drawChip(colorStr: string, label: string, x: number, y: number, alpha: number, hi: number, C_INK: string): void {
const ctx = ctxRef.current!;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(x, y + 8, 4 + hi * 2.5, 0, Math.PI * 2);
ctx.fillStyle = colorStr;
ctx.fill();
ctx.font = "700 11px " + FONT;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = C_INK;
ctx.fillText(label, x + 12, y + 8);
ctx.globalAlpha = 1;
}
function draw(p: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const C_A = cssv("--chart-1") || color;
const C_B = cssv("--chart-3");
const C_INK = cssv("--ink");
const C_MUTE = cssv("--mute");
const C_GRID = cssv("--grid");
const C_PAPER = cssv("--paper");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
// 중심·반경 — 필드 안쪽. 오른쪽에 범례 침을 둘 자리를 남긴다
const legendW = 76;
const CX = (W - legendW) / 2 + 6;
const CY = H / 2 + 6;
const R = Math.min(W - legendW - 24, H - 44) / 2;
const axisP = Math.min(1, p / 0.08); // 축 라벨 등장(첫 8%)
// 거미줄 그리드 — 25/50/75/100 농도 고리 + 축 살
ctx.lineWidth = 1;
for (let ring = 25; ring <= 100; ring += 25) {
polyGrid(CX, CY, R, ring);
ctx.strokeStyle = C_GRID;
ctx.stroke();
}
for (let i = 0; i < N; i++) {
const a = angleAt(i);
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(a) * R, CY + Math.sin(a) * R);
ctx.strokeStyle = C_GRID;
ctx.stroke();
}
// 축 라벨 — 농도 고리가 뜬 뒤 반경 밖에 붙는다
ctx.font = "700 10px " + FONT;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let l = 0; l < N; l++) {
const la = angleAt(l);
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText(AXES[l], CX + Math.cos(la) * (R + 14), CY + Math.sin(la) * (R + 12));
ctx.globalAlpha = 1;
}
// 모핑 값열 — A 팀은 고정, B 팀은 정점별 진행으로 A→B 사이를 간다
const morphing = A_VAL.slice();
const back = Math.max(0, (p - 0.75) / (BACK1 - 0.75)); // 0.75~0.90 복귀
for (let m = 0; m < N; m++) {
const fwd = easeOutCubic(vertexP(m, p, MORPH1 - MORPH0, MORPH0));
const v = A_VAL[m] + (B_VAL[m] - A_VAL[m]) * fwd;
// 복귀 — B 에 도달한 뒤 다시 A 로. 복귀도 정점 배율을 그대로 쓴다
const bk = easeOutCubic(Math.max(0, (back - VTX[m].off * 0.5) * VTX[m].rate));
morphing[m] = v + (A_VAL[m] - v) * Math.min(1, bk);
}
// 두 층 — A 팀(고정, 뒤) 과 모핑 층(앞). 반투명 면이 겹쳐 교차 영역이 진해진다
drawLayer(CX, CY, R, A_VAL, C_A, axisP);
drawLayer(CX, CY, R, morphing, C_B, Math.min(1, axisP * 1.2));
// 범례 칩 — 오른쪽 세로. 모핑이 끝난 뒤(0.62~0.75) 강조로 커진다
const hi = Math.max(0, Math.min(1, (p - MORPH1) / 0.13));
drawChip(C_A, "A 팀", W - 68, H / 2 - 26, axisP * 0.9, hi * 0.6, C_INK);
drawChip(C_B, "B 팀", W - 68, H / 2 + 10, Math.min(1, axisP * 1.2), 0.6 + hi * 0.4, C_INK);
// 평균 라벨 — 모핑 층의 현재 평균. 모핑이 끝나면 뜬다
if (p > MORPH1) {
let sum = 0;
for (let s = 0; s < N; s++) sum += morphing[s];
ctx.font = "700 11px " + FONT;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = C_INK;
ctx.fillText("평균 " + Math.round(sum / N), CX - R, CY - R - 8);
}
}
function frame(now: number): void {
if (!t0Ref.current) t0Ref.current = now;
const p = ((now - t0Ref.current) / 1000 % LOOP) / LOOP;
draw(p);
rafRef.current = requestAnimationFrame(frame);
}
function replay(): void {
t0Ref.current = 0;
}
function onResize(): void {
fit();
if (reduced()) draw(0.75);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프 없이 완성 상태(두 다각형이 겹친 B 팀 도달 장면) 한 장만 그린다
draw(0.75);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 0 0-90.08 155.86l12.47 37.39a8 8 0 0 0 10.12 10.12l37.39-12.47A104 104 0 1 0 128 24m0 136a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M176 128a48 48 0 1 1-48-48a48 48 0 0 1 48 48"/></g></svg>
<span>여섯 축 다각형이 정점마다 다른 속도로 모핑합니다 — <code>lerp</code>·<code>globalAlpha</code>·<code>정점 시차</code></span>
</p>
<div className={styles.field} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef} role="img" aria-label={CV_LABEL}></canvas>
</div>
<div className={styles.bar}>
<button className={styles.btn} type="button" onClick={replay}>
<svg className={styles.bico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
<span>다시 그리기</span>
</button>
<table className={styles.srOnly}>
<caption>여섯 축 역량 점수(백점 만점) — A 팀과 B 팀</caption>
<thead><tr><th scope="col">축</th><th scope="col">A 팀</th><th scope="col">B 팀</th></tr></thead>
<tbody>
{AXES.map((ax, i) => (
<tr key={ax}><th scope="row">{ax}</th><td>{A_VAL[i]}</td><td>{B_VAL[i]}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
}08 레이더 그래프 변형 — 측정 누적면적 7.3% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종
이 데모가 속한 글
canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종canvas 그래프 그리기는 데이터 배열을 x·y 좌표로 바꿔 캔버스 위에 선·막대·원호로 얹는 일입니다. 라이브러리와 CDN 없이 값이 그려지고 차오르고 모여드는 차트 아홉 종을 만들었고, 파일만 열면 복사한 그대 이 글의 데모 전부 보기


