08Radar morph
The six-axis polygon morphs from team A to team B with each vertex easing on its own clock; two translucent fills overlap so the shared region deepens.
Best forSkill comparison, two candidates side by side
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>Six-axis polygons morph at a different speed per vertex — <code>lerp</code>·<code>globalAlpha</code>·<code>Vertex stagger</code></span>
</p>
<div class="rm__field" id="field" role="application" aria-label="Radar chart morph test board" tabindex="0">
<canvas class="rm__cv" id="cv" role="img" aria-label="Six-axis capability radar chart — team A's polygon turns into team B's at per-vertex speeds while two translucent fills overlap"></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>Redraw</span>
</button>
<table class="sr-only">
<caption>Six-axis capability scores (out of 100) — team A and team B</caption>
<thead><tr><th scope="col">Axis</th><th scope="col">Team A</th><th scope="col">Team B</th></tr></thead>
<tbody>
<tr><th scope="row">Speed</th><td>72</td><td>58</td></tr>
<tr><th scope="row">Accuracy</th><td>64</td><td>82</td></tr>
<tr><th scope="row">Stability</th><td>80</td><td>90</td></tr>
<tr><th scope="row">Exploration</th><td>52</td><td>74</td></tr>
<tr><th scope="row">Collaboration</th><td>76</td><td>60</td></tr>
<tr><th scope="row">Endurance</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;
}
}// 레이더 차트 모핑 — 6Axis 다각형이 팀 A 값에서 팀 B 값으로 정점마다 서로 다른 속도로 바뀐다.
// 정점별 속도차는 시작 시각(offset)과 Endurance 배율(rate) 두 개로 만든다 — 같은 시각에 출발해도
// 배율이 다르면 도착이 어긋나고, 다각형이 찌그러졌다가 제 모양으로 펴지는 모핑의 핵이 그 어긋남이다.
// 두 팀 면은 반투명 fill(알파 0.25)로 겹친다 — source-over 겹침으로 교차 영역이 자연히 진해진다.
// 2초 루프: 0~0.08 Axis 라벨 등장, 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';
// 고정 시드 — 여섯 Axis 역량 점수 두 세트(백점 만점)
var AXES = ['Speed', 'Accuracy', 'Stability', 'Exploration', 'Collaboration', 'Endurance'];
var A_VAL = [88, 82, 92, 78, 90, 84];
var B_VAL = [34, 26, 40, 22, 30, 28];
// 정점별 모핑 Speed — 시작 시차(offset)와 Endurance 배율(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; // 모핑 시작 — 그 전까지 Axis 라벨이 뜬다
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); }
// Axis 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));
}
// 반경 — Value(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); // Axis 라벨 등장(첫 8%)
// 거미줄 그리드 — 25/50/75/100 농도 고리 + Axis 살
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();
}
// Axis 라벨 — 농도 고리가 뜬 뒤 반경 밖에 붙는다
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);
}
// 두 층 — Team 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, 'Team A', H / 2 - 26, axisP * 0.9, hi * 0.6);
drawChip(C_B, 'Team B', H / 2 + 10, Math.min(1, axisP * 1.2), 0.6 + hi * 0.4);
// Average 라벨 — 모핑 층의 현재 Average. 모핑이 끝나면 뜬다
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('Average ' + Math.round(sum / N), CX - R, CY - R - 8);
}
}
// 농도 고리 경로 — Value 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 — 루프 없이 완성 상태(두 다각형이 겹친 Team 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 Radar morph — Measured area 7.3% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · JavaScript Chart Drawing, 9 Ways — No Library
The post this demo lives in
JavaScript Chart Drawing, 9 Ways — No LibraryA javascript chart is a data array turned into x·y coordinates painted onto a canvas as lines, bars, and arcs. These nin See all demos in this post
If you like this effect - similar demos
Dashboard UI04 Radar chart unfolding from the centerCSS Chart Design: 9 SVG Graphs, No Library
Dashboard UI04 A Pie Chart That Glides Its Slice Angles to New Shares When the Period ChangesPie Chart CSS: 9 Slices That Pop, Tilt and Morph
Dashboard UI04 Donut chart card with center counterDashboard Sidebar Design + 9 Widget UI Pieces