import "./ui_template_progress.css"; /* ============================================================================= * ui_template_progress.ts * 공통 프로그레스 서클 (원형 진행 표시) * * 뷰포트 위에 얹어 "지금 무엇을 얼마나 불러왔는지"를 보여 주는 용도. * 진행률을 모르는 구간은 set()에 ratio를 주지 않으면 회전 애니메이션으로 표시한다. * 색상/치수는 theme.css 변수만 사용한다. * ========================================================================== */ const SVG_NS = "http://www.w3.org/2000/svg"; /** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */ const RADIUS = 42; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; export interface ProgressCircleOptions { /** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */ size?: number; /** 서클 아래 안내 문구. */ label?: string; /** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */ overlay?: boolean; } export interface ProgressCircleHandle { root: HTMLDivElement; /** 진행률(0~1)과 문구 갱신. ratio가 null이면 진행률 미상(회전 표시). */ set: (ratio: number | null, label?: string) => void; /** 화면에서 제거. */ remove: () => void; } export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle { const root = document.createElement("div"); root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : ""); root.setAttribute("role", "status"); root.setAttribute("aria-live", "polite"); if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`); const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("class", "ui-progress-circle__svg"); svg.setAttribute("viewBox", "0 0 100 100"); const track = document.createElementNS(SVG_NS, "circle"); track.setAttribute("class", "ui-progress-circle__track"); track.setAttribute("cx", "50"); track.setAttribute("cy", "50"); track.setAttribute("r", String(RADIUS)); const bar = document.createElementNS(SVG_NS, "circle"); bar.setAttribute("class", "ui-progress-circle__bar"); bar.setAttribute("cx", "50"); bar.setAttribute("cy", "50"); bar.setAttribute("r", String(RADIUS)); bar.setAttribute("stroke-dasharray", String(CIRCUMFERENCE)); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE)); svg.append(track, bar); const percent = document.createElement("span"); percent.className = "ui-progress-circle__percent"; const label = document.createElement("span"); label.className = "ui-progress-circle__label"; label.textContent = options.label ?? ""; // 회전은 진행률과 **따로 논다**(2026-09-04 사용자 지시) — 무거운 단계에서 호가 // 안 늘어도 도넛은 계속 돌아야 "멈춘 것"으로 안 보인다. 바깥 껍데기만 CSS로 돌리고 // 안쪽 svg는 12시 고정이라, 호·숫자는 제자리에서 갱신된다. const spin = document.createElement("div"); spin.className = "ui-progress-circle__spin"; spin.append(svg); const dial = document.createElement("div"); dial.className = "ui-progress-circle__dial"; dial.append(spin, percent); root.append(dial, label); function set(ratio: number | null, nextLabel?: string): void { if (nextLabel !== undefined) label.textContent = nextLabel; if (ratio === null) { // 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다). bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); percent.textContent = ""; return; } const clamped = Math.min(1, Math.max(0, ratio)); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); percent.textContent = `${Math.round(clamped * 100)}%`; } set(0, options.label); return { root, set, remove: () => root.remove(), }; }