06게이지 바늘 회전
반원 게이지의 바늘이 목표 각을 지나쳐 스프링 감쇠로 되돌아와 멈춘다(오버슈트). 눈금 색은 현재 구간만 채색되고 값은 최종 각도에서 확정된다.
쓰임KPI 목표 달성률, 서비스 상태
Math.cosarctextAlign

<main class="stage">
<div class="gn" id="gn">
<p class="gn__hint">
<svg class="gn__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 64v144H32V48h176a16 16 0 0 1 16 16" opacity=".2"/><path d="M232 208a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V48a8 8 0 0 1 16 0v108.69l50.34-50.35a8 8 0 0 1 11.32 0L128 132.69L180.69 80H160a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8v40a8 8 0 0 1-16 0V91.31l-58.34 58.35a8 8 0 0 1-11.32 0L96 123.31l-56 56V200h184a8 8 0 0 1 8 8"/></g></svg>
<span>바늘이 목표각을 지나쳐 감쇠 진동으로 되돌아와 멈춥니다 — <code>감쇠 사인파</code>·<code>arc</code>·<code>눈금 진행 채색</code></span>
</p>
<div class="gn__field" id="field" role="application" aria-label="게이지 바늘 실험판" tabindex="0">
<canvas class="gn__cv" id="cv" role="img" aria-label="목표 달성률 반원 게이지 — 바늘이 값 72 퍼센트를 지나쳤다가 감쇠 진동하며 멈추고 눈금이 순서대로 채색되는 모습"></canvas>
</div>
<div class="gn__bar">
<button class="gn__btn" id="replay" type="button">
<svg class="gn__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>목표 달성률 게이지 — 값과 눈금 3구간</caption>
<thead><tr><th scope="col">항목</th><th scope="col">값</th></tr></thead>
<tbody>
<tr><th scope="row">달성률</th><td>72%</td></tr>
<tr><th scope="row">낮음 구간(0~40)</th><td>파스텔 딥민트</td></tr>
<tr><th scope="row">중간 구간(40~70)</th><td>파스텔 블루</td></tr>
<tr><th scope="row">높음 구간(70~100)</th><td>파스텔 레드오렌지</td></tr>
</tbody>
</table>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 게이지 바늘 — 움직임은 전부 캔버스 rAF 루프 안(감쇠 사인파 스윕 + 눈금 진행 채색 + 값 팝).
// CSS 애니메이션이 0개여야 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 무대는 $stage-paper(흰) — 차트는 밝은 무대가 정석. 눈금 3구간은 저=3(민트)·중=1(블루)·고=2(레드오렌지) 파스텔 순서.
$duration: $dur-loop;
$easing: $ease-spring; // 감쇠 진동을 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;
}
.gn {
// 캔버스가 읽는 색 — JS 가 getComputedStyle 로 꺼내 쓴다. vivid 리터럴은 두지 않는다(BD26)
--chart-1: #{$chart-1};
--chart-2: #{$chart-2};
--chart-3: #{$chart-3};
--ink: #{$subject-ink};
--mute: #{rgba($subject-ink, .62)};
--grid: #{rgba($subject-ink, .18)};
--track: #{rgba($subject-ink, .06)};
--paper: #{$stage-paper};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gn__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;
}
.gn__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;
}
.gn__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.gn__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);
}
.gn__cv { display: block; width: 100%; height: 100%; }
.gn__bar {
display: flex; align-items: center; gap: $sp-2;
}
.gn__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;
}
.gn__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gn__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.gn { gap: $sp-1; }
.gn__field { height: 112px; }
.gn__hint { font-size: 10px; }
.gn__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말고 할 애니메이션이 없다.
// 완성 상태 한 장이 남으므로 "다시 그리기"는 여전히 정지 이미지를 다시 그린다.
@media (prefers-reduced-motion: reduce) {
.gn__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;
}
.gn {
--chart-1: #2f6df6;
--chart-2: #c2361a;
--chart-3: #2b9d74;
--ink: #17141a;
--mute: rgba(23, 20, 26, 0.62);
--grid: rgba(23, 20, 26, 0.18);
--track: rgba(23, 20, 26, 0.06);
--paper: #ffffff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gn__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;
}
.gn__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;
}
.gn__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.gn__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);
}
.gn__cv {
display: block;
width: 100%;
height: 100%;
}
.gn__bar {
display: flex;
align-items: center;
gap: 8px;
}
.gn__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;
}
.gn__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.gn__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.gn {
gap: 4px;
}
.gn__field {
height: 112px;
}
.gn__hint {
font-size: 10px;
}
.gn__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.gn__btn {
cursor: default;
}
}// 게이지 바늘 — 반원(180도) 게이지에서 바늘이 목표각(값 72)을 지나쳐 스프링 감쇠 진동으로 되돌아와 멈춘다.
// 세 겹이 함께 움직인다:
// 1) 스윕 진행 u(0..1) → 바늘 각도 = 목표각 * 감쇠 사인파(u). sin 으로 목표를 넘겼다 되돌아오는
// 오버슛을 만들고 e^(-감삭*u) 로 진폭을 줄여 몇 번 흔들리다 멈춘다 — 속성 트윈이 아니라 계산.
// 2) 눈금 3구간(저=3번·중=1번·고=2번 계열)은 바늘 현재 각도까지만 채색, 나머지는 --grid 트랙.
// 3) 값 숫자(72)는 바늘이 완전히 멈춘 뒤(루프 마지막 구간)에만 확정 표시 — 그 전엔 현재각을 조금씩 보인다.
// 루프: 0~0.1 눈금 등장, 0.1~0.75 스윕+오버슈트 진동, 0.75~0.9 값 확정+팝, 0.9~1.0 유지.
var root = document.getElementById('gn');
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_1 = css.getPropertyValue('--chart-1').trim();
var C_2 = css.getPropertyValue('--chart-2').trim();
var C_3 = 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_TRACK = css.getPropertyValue('--track').trim();
var C_PAPER = css.getPropertyValue('--paper').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 값·눈금 — 72(중간 구간 끝자락, 높음 구간 살짝 침범). 3구간은 파스텔 순서 저=3·중=1·고=2
var VALUE = 72;
var SEGS = [
{ from: 0, to: 40, c: 'C3' },
{ from: 40, to: 70, c: 'C1' },
{ from: 70, to: 100, c: 'C2' }
];
var LOOP = 2.4; // 한 사이클 2.4초 — 캡처 창(2초) 내내 스윕·진동이 진행 중이다
var SWEEP = 0.65; // 0.1~0.75 — 스윕+오버슈트 진동 구간의 길이
var DECAY = 5.2; // 감쇠 계수 — 진폭이 줄며 흔들리다 멈춘다
var OMEGA = 10.5; // 진동 각진동수 — 목표를 지나쳤다 돌아오며 몇 번 흔들린다
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 cx() { return W / 2; }
function cy() { return H - 30; }
function R() { return Math.min((W - 44) / 2, H - 48); }
// 값(0~100) → 캔버스 각도(라디안). 캔버스는 y 축이 아래로 열려 있으므로
// 위쪽 반원은 PI(왼쪽) → 1.5PI(꼭대기) → 2PI(오른쪽) 순으로 돈다 — 시계 방향(각도 증가)
function angOf(v) { return Math.PI + Math.PI * (v / 100); }
// 값 → 반경 r 위치(극 → 직교). 각이 커질수록 시계 방향(왼쪽에서 꼭대기를 지나 오른쪽)
function pt(v, r) {
var a = angOf(v);
return { x: cx() + Math.cos(a) * r, y: cy() + Math.sin(a) * r };
}
// 스윕 진행 u(0..1) → 바늘 값(0~100+). 감쇠 사인파로 목표를 지나쳤다 되돌아온다:
// 목표 이상 성분 VALUE * (1 - e^(-DECAY*u) * cos(OMEGA*u)) — u=0 에서 0, u→1 에서 VALUE 로 수렴
function needleVal(u) {
return VALUE * (1 - Math.exp(-DECAY * u) * Math.cos(OMEGA * u));
}
// 목표를 살짝 넘겼다가 돌아오는 오버슛 — 값 팝에 쓰는 감
function easeOutBack(t) {
var c = 1.7;
return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2);
}
function draw(p) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
// 구간별 눈금 게이지 — 첫 10% 동안 트랙이 떠오른다(위에서 아래로 그린 값 내려붙기)
var trackP = Math.min(1, p / 0.1);
// 바늘 현재 값 — 0.1~0.75 구간에서 스윕+진동, 이후 VALUE 로 확정
var u = Math.max(0, Math.min(1, (p - 0.1) / SWEEP));
var nv = p < 0.1 ? 0 : needleVal(u);
var lw = 22;
// 트랙 전체(옅은) — 등장하는 동안 알파로 뜬다. PI→2PI 시계 방향은 위쪽 반원을 지난다
ctx.globalAlpha = trackP;
ctx.lineCap = 'butt';
ctx.lineWidth = lw;
ctx.beginPath();
ctx.arc(cx(), cy(), R(), angOf(0), angOf(100));
ctx.strokeStyle = C_TRACK;
ctx.stroke();
// 눈금 3구간 채색 — 바늘 현재 각도까지만 채우고, 못 미친 나머지는 --grid 로 남긴다
for (var i = 0; i < SEGS.length; i++) {
var seg = SEGS[i];
var col = seg.c === 'C1' ? C_1 : seg.c === 'C2' ? C_2 : C_3;
var to = Math.min(seg.to, nv);
if (to > seg.from) {
ctx.beginPath(); // 닿은 만큼 — 계열색
ctx.arc(cx(), cy(), R(), angOf(seg.from), angOf(to));
ctx.strokeStyle = col;
ctx.stroke();
}
if (to < seg.to) {
ctx.beginPath(); // 못 미친 나머지 — grid 톤
ctx.arc(cx(), cy(), R(), angOf(to), angOf(seg.to));
ctx.strokeStyle = C_GRID;
ctx.stroke();
}
}
ctx.globalAlpha = 1;
// 눈금 라벨 0·50·100 — 트랙 위 바깥. 등장 후 함께 뜬다
ctx.font = '700 10px ' + FONT;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.globalAlpha = trackP;
var LABS = [{ v: 0, s: '0' }, { v: 50, s: '50' }, { v: 100, s: '100' }];
for (var l = 0; l < LABS.length; l++) {
var lp2 = pt(LABS[l].v, R() + 12);
ctx.fillStyle = C_MUTE;
ctx.fillText(LABS[l].s, lp2.x, lp2.y);
}
ctx.globalAlpha = 1;
// 바늘 — 중심을 지나 현재각으로 뻗는다. 꽁무니는 음의 반경(중심 반대편 10px) — 회전축 무게중심
var tip = pt(nv, R() - 22);
var tail = pt(nv, -10);
ctx.beginPath();
ctx.moveTo(tail.x, tail.y);
ctx.lineTo(tip.x, tip.y);
ctx.strokeStyle = C_INK;
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.stroke();
// 축 허브 — 종이색 속에 잉크 테두리
ctx.beginPath();
ctx.arc(cx(), cy(), 8, 0, Math.PI * 2);
ctx.fillStyle = C_PAPER;
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = C_INK;
ctx.stroke();
// 값 숫자 — 바늘이 완전히 멈춘 뒤(0.75~)에만 확정 표시. 그 전엔 현재각을 조금씩 보인다
var settle = Math.max(0, Math.min(1, (p - 0.75) / 0.15)); // 0.75~0.9 값 확정+팝
var shown = settle >= 1 ? VALUE : Math.max(0, Math.min(100, nv));
var popR = settle > 0 ? easeOutBack(Math.min(1, settle * 1.6)) : 0;
var numY = cy() - R() * 0.36;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.font = '700 ' + Math.round(22 + 6 * popR) + 'px ' + FONT;
ctx.globalAlpha = settle >= 1 ? 1 : Math.min(1, p / 0.2) * 0.5;
ctx.fillStyle = C_INK;
ctx.fillText(String(Math.round(shown)), cx(), numY);
ctx.font = '600 10px ' + FONT;
ctx.globalAlpha = 1;
ctx.fillStyle = C_MUTE;
ctx.fillText('달성률(%)', cx(), numY + 14);
}
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 — 루프 없이 완성 상태(바늘이 72 에 멈추고 값이 확정된 한 장)만 그린다
draw(0.95);
} else {
raf = requestAnimationFrame(frame);
}// GaugeNeedleChart — vanilla 판(run/305/demo/06-chart-canvas-gauge-needle/index.html) 의 React 포트.
// 반원 게이지에서 바늘이 목표각(값 72)을 지나쳐 감쇠 사인파로 진동하며 되돌아와 멈추고,
// 눈금 3구간은 바늘 현재 각도까지만 채색된다. 값 숫자는 바늘이 멈춘 뒤 확정 표시.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GaugeNeedleChart.module.scss";
export type GaugeNeedleChartProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type Seg = { from: number; to: number; c: "C1" | "C2" | "C3" };
const VALUE = 72;
// 눈금 3구간 — 파스텔 순서 저=3(민트)·중=1(블루)·고=2(레드오렌지)
const SEGS: Seg[] = [
{ from: 0, to: 40, c: "C3" },
{ from: 40, to: 70, c: "C1" },
{ from: 70, to: 100, c: "C2" }
];
const LOOP = 2; // 한 사이클 2초
const SWEEP = 0.65; // 0.1~0.75 — 스윕+오버슈트 진동 구간의 길이
const DECAY = 5.2; // 감쇠 계수 — 진폭이 줄며 흔들리다 멈춘다
const OMEGA = 10.5; // 진동 각진동수 — 목표를 지나쳤다 돌아오며 몇 번 흔들린다
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "게이지 바늘 실험판";
const CV_LABEL = "목표 달성률 반원 게이지 — 바늘이 값 72 퍼센트를 지나쳤다가 감쇠 진동하며 멈추고 눈금이 순서대로 채색되는 모습";
export default function GaugeNeedleChart({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $chart-1
}: GaugeNeedleChartProps) {
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 cx(): number { return sizeRef.current.w / 2; }
function cy(): number { return sizeRef.current.h - 30; }
function R(): number { return Math.min((sizeRef.current.w - 44) / 2, sizeRef.current.h - 48); }
// 값(0~100) → 캔버스 각도(라디안). 캔버스는 y 축이 아래로 열려 있으므로
// 위쪽 반원은 PI(왼쪽) → 1.5PI(꼭대기) → 2PI(오른쪽) 순으로 돈다 — 시계 방향(각도 증가)
function angOf(v: number): number { return Math.PI + Math.PI * (v / 100); }
// 값 → 반경 r 위치(극 → 직교). 각이 커질수록 시계 방향(왼쪽에서 꼭대기를 지나 오른쪽)
function pt(v: number, r: number): { x: number; y: number } {
const a = angOf(v);
return { x: cx() + Math.cos(a) * r, y: cy() + Math.sin(a) * r };
}
// 스윕 진행 u(0..1) → 바늘 값(0~100+). 감쇠 사인파로 목표를 지나쳤다 되돌아온다:
// 목표 이상 성분 VALUE * (1 - e^(-DECAY*u) * cos(OMEGA*u)) — u=0 에서 0, u→1 에서 VALUE 로 수렴
function needleVal(u: number): number {
return VALUE * (1 - Math.exp(-DECAY * u) * Math.cos(OMEGA * u));
}
// 목표를 살짝 넘겼다가 돌아오는 오버슛 — 값 팝에 쓰는 감
function easeOutBack(t: number): number {
const c = 1.7;
return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2);
}
function draw(p: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const C_1 = cssv("--chart-1") || color;
const C_2 = cssv("--chart-2");
const C_3 = cssv("--chart-3");
const C_INK = cssv("--ink");
const C_MUTE = cssv("--mute");
const C_GRID = cssv("--grid");
const C_TRACK = cssv("--track");
const C_PAPER = cssv("--paper");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
// 구간별 눈금 게이지 — 첫 10% 동안 트랙이 떠오른다
const trackP = Math.min(1, p / 0.1);
// 바늘 현재 값 — 0.1~0.75 구간에서 스윕+진동, 이후 VALUE 로 확정
const u = Math.max(0, Math.min(1, (p - 0.1) / SWEEP));
const nv = p < 0.1 ? 0 : needleVal(u);
const lw = 13;
// 트랙 전체(옅은) — 등장하는 동안 알파로 뜬다. PI→2PI 시계 방향은 위쪽 반원을 지난다
ctx.globalAlpha = trackP;
ctx.lineCap = "butt";
ctx.lineWidth = lw;
ctx.beginPath();
ctx.arc(cx(), cy(), R(), angOf(0), angOf(100));
ctx.strokeStyle = C_TRACK;
ctx.stroke();
// 눈금 3구간 채색 — 바늘 현재 각도까지만 채우고, 못 미친 나머지는 --grid 로 남긴다
for (let i = 0; i < SEGS.length; i++) {
const seg = SEGS[i];
const col = seg.c === "C1" ? C_1 : seg.c === "C2" ? C_2 : C_3;
const to = Math.min(seg.to, nv);
if (to > seg.from) {
ctx.beginPath(); // 닿은 만큼 — 계열색
ctx.arc(cx(), cy(), R(), angOf(seg.from), angOf(to));
ctx.strokeStyle = col;
ctx.stroke();
}
if (to < seg.to) {
ctx.beginPath(); // 못 미친 나머지 — grid 톤
ctx.arc(cx(), cy(), R(), angOf(to), angOf(seg.to));
ctx.strokeStyle = C_GRID;
ctx.stroke();
}
}
ctx.globalAlpha = 1;
// 눈금 라벨 0·50·100 — 트랙 위 바깥. 등장 후 함께 뜬다
ctx.font = "700 10px " + FONT;
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.globalAlpha = trackP;
const LABS = [{ v: 0, s: "0" }, { v: 50, s: "50" }, { v: 100, s: "100" }];
for (let l = 0; l < LABS.length; l++) {
const lp2 = pt(LABS[l].v, R() + 12);
ctx.fillStyle = C_MUTE;
ctx.fillText(LABS[l].s, lp2.x, lp2.y);
}
ctx.globalAlpha = 1;
// 바늘 — 중심을 지나 현재각으로 뻗는다. 꽁무니는 음의 반경(중심 반대편 10px) — 회전축 무게중심
const tip = pt(nv, R() - 22);
const tail = pt(nv, -10);
ctx.beginPath();
ctx.moveTo(tail.x, tail.y);
ctx.lineTo(tip.x, tip.y);
ctx.strokeStyle = C_INK;
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.stroke();
// 축 허브 — 종이색 속에 잉크 테두리
ctx.beginPath();
ctx.arc(cx(), cy(), 6, 0, Math.PI * 2);
ctx.fillStyle = C_PAPER;
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = C_INK;
ctx.stroke();
// 값 숫자 — 바늘이 완전히 멈춘 뒤(0.75~)에만 확정 표시. 그 전엔 현재각을 조금씩 보인다
const settle = Math.max(0, Math.min(1, (p - 0.75) / 0.15)); // 0.75~0.9 값 확정+팝
const shown = settle >= 1 ? VALUE : Math.max(0, Math.min(100, nv));
const popR = settle > 0 ? easeOutBack(Math.min(1, settle * 1.6)) : 0;
const numY = cy() - R() * 0.36;
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.font = "700 " + Math.round(22 + 6 * popR) + "px " + FONT;
ctx.globalAlpha = settle >= 1 ? 1 : Math.min(1, p / 0.2) * 0.5;
ctx.fillStyle = C_INK;
ctx.fillText(String(Math.round(shown)), cx(), numY);
ctx.font = "600 10px " + FONT;
ctx.globalAlpha = 1;
ctx.fillStyle = C_MUTE;
ctx.fillText("달성률(%)", cx(), numY + 14);
}
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.95);
}
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 — 루프 없이 완성 상태(바늘이 72 에 멈추고 값이 확정된 한 장)만 그린다
draw(0.95);
} 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="M224 64v144H32V48h176a16 16 0 0 1 16 16" opacity=".2"/><path d="M232 208a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V48a8 8 0 0 1 16 0v108.69l50.34-50.35a8 8 0 0 1 11.32 0L128 132.69L180.69 80H160a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8v40a8 8 0 0 1-16 0V91.31l-58.34 58.35a8 8 0 0 1-11.32 0L96 123.31l-56 56V200h184a8 8 0 0 1 8 8"/></g></svg>
<span>바늘이 목표각(72)을 지나쳐 감쇠 진동으로 되돌아와 멈춥니다 — <code>감쇠 사인파</code>·<code>arc</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>목표 달성률 게이지 — 값과 눈금 3구간</caption>
<thead><tr><th scope="col">항목</th><th scope="col">값</th></tr></thead>
<tbody>
<tr><th scope="row">달성률</th><td>72%</td></tr>
<tr><th scope="row">낮음 구간(0~40)</th><td>파스텔 딥민트</td></tr>
<tr><th scope="row">중간 구간(40~70)</th><td>파스텔 블루</td></tr>
<tr><th scope="row">높음 구간(70~100)</th><td>파스텔 레드오렌지</td></tr>
</tbody>
</table>
</div>
</div>
</main>
);
}06 게이지 바늘 회전 — 측정 누적면적 10.783% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종
이 데모가 속한 글
canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종canvas 그래프 그리기는 데이터 배열을 x·y 좌표로 바꿔 캔버스 위에 선·막대·원호로 얹는 일입니다. 라이브러리와 CDN 없이 값이 그려지고 차오르고 모여드는 차트 아홉 종을 만들었고, 파일만 열면 복사한 그대 이 글의 데모 전부 보기


