/** * 게이지 관련 기본 클래스 * @module GaugeBase */ class GaugeBase { constructor(config = {}) { this.config = { size: 400, strokeWidth: 20, maxValue: 100, currentValue: 0, padding: 10, startAngle: -90, endAngle: 270, animationDuration: 800, easing: "ease-out", ...config, }; this.svg = null; this.path = null; this.pathLength = 0; } /** * 각도를 라디안으로 변환 * @param {number} angle - 각도 * @returns {number} */ static degreesToRadians(angle) { return (angle * Math.PI) / 180; } /** * 라디안을 각도로 변환 * @param {number} radians - 라디안 * @returns {number} */ static radiansToDegrees(radians) { return (radians * 180) / Math.PI; } /** * 원형 경로의 좌표 계산 * @param {number} cx - 중심 X * @param {number} cy - 중심 Y * @param {number} radius - 반지름 * @param {number} angle - 각도 * @returns {Object} */ static polarToCartesian(cx, cy, radius, angle) { const radians = this.degreesToRadians(angle); return { x: cx + radius * Math.cos(radians), y: cy + radius * Math.sin(radians), }; } /** * SVG 원호 경로 생성 * @param {number} cx - 중심 X * @param {number} cy - 중심 Y * @param {number} radius - 반지름 * @param {number} startAngle - 시작 각도 * @param {number} endAngle - 종료 각도 * @returns {string} */ static describeArc(cx, cy, radius, startAngle, endAngle) { const start = this.polarToCartesian(cx, cy, radius, endAngle); const end = this.polarToCartesian(cx, cy, radius, startAngle); const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1"; return [ "M", start.x, start.y, "A", radius, radius, 0, largeArcFlag, 0, end.x, end.y, ].join(" "); } /** * 진행률을 각도로 변환 * @param {number} percent - 진행률 (0-1) * @param {number} startAngle - 시작 각도 * @param {number} endAngle - 종료 각도 * @returns {number} */ static percentToAngle(percent, startAngle = -90, endAngle = 270) { const totalAngle = endAngle - startAngle; return startAngle + totalAngle * percent; } /** * SVG 요소 생성 * @param {string} tag - 태그명 * @param {Object} attrs - 속성 * @returns {SVGElement} */ static createSVGElement(tag, attrs = {}) { const element = document.createElementNS("http://www.w3.org/2000/svg", tag); Object.entries(attrs).forEach(([key, value]) => { element.setAttribute(key, value); }); return element; } /** * 경로 길이 계산 * @param {SVGPathElement} path - SVG 경로 요소 * @returns {number} */ static getPathLength(path) { return path.getTotalLength(); } /** * 경로상의 특정 지점 좌표 * @param {SVGPathElement} path - SVG 경로 요소 * @param {number} percent - 위치 (0-1) * @returns {DOMPoint} */ static getPointAtPercent(path, percent) { const length = path.getTotalLength(); return path.getPointAtLength(length * percent); } /** * 이징 함수 * @param {number} t - 시간 (0-1) * @param {string} type - 이징 타입 * @returns {number} */ static easing(t, type = "ease-out") { const easings = { linear: (t) => t, "ease-in": (t) => t * t, "ease-out": (t) => t * (2 - t), "ease-in-out": (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), "ease-in-cubic": (t) => t * t * t, "ease-out-cubic": (t) => --t * t * t + 1, "ease-in-out-cubic": (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, bounce: (t) => { if (t < 1 / 2.75) { return 7.5625 * t * t; } else if (t < 2 / 2.75) { return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75; } else if (t < 2.5 / 2.75) { return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375; } else { return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; } }, }; return easings[type] ? easings[type](t) : easings["ease-out"](t); } /** * 값을 범위 내로 제한 * @param {number} value - 값 * @param {number} min - 최소값 * @param {number} max - 최대값 * @returns {number} */ static clamp(value, min, max) { return Math.min(Math.max(value, min), max); } /** * 값을 범위로 매핑 * @param {number} value - 값 * @param {number} inMin - 입력 최소값 * @param {number} inMax - 입력 최대값 * @param {number} outMin - 출력 최소값 * @param {number} outMax - 출력 최대값 * @returns {number} */ static map(value, inMin, inMax, outMin, outMax) { return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; } /** * 선형 보간 * @param {number} start - 시작값 * @param {number} end - 종료값 * @param {number} t - 시간 (0-1) * @returns {number} */ static lerp(start, end, t) { return start + (end - start) * t; } } /** * 원형 게이지 클래스 */ class CircularGauge extends GaugeBase { constructor(config = {}) { super(config); this.centerX = this.config.size / 2; this.centerY = this.config.size / 2; this.radius = (this.config.size - this.config.strokeWidth - this.config.padding * 2) / 2; } /** * 게이지 초기화 * @param {string|Element} container - 컨테이너 선택자 또는 요소 * @returns {SVGElement} */ init(container) { const element = typeof container === "string" ? document.querySelector(container) : container; if (!element) { console.error("Container not found"); return null; } // SVG 생성 this.svg = GaugeBase.createSVGElement("svg", { width: this.config.size, height: this.config.size, viewBox: `0 0 ${this.config.size} ${this.config.size}`, }); // 배경 원 const bgPath = this._createPath("background"); this.svg.appendChild(bgPath); // 진행률 원 this.path = this._createPath("progress"); this.svg.appendChild(this.path); // 경로 길이 설정 this.pathLength = GaugeBase.getPathLength(this.path); this.path.style.strokeDasharray = this.pathLength; this.path.style.strokeDashoffset = this.pathLength; element.appendChild(this.svg); return this.svg; } /** * 경로 생성 * @private */ _createPath(type = "progress") { const pathData = GaugeBase.describeArc( this.centerX, this.centerY, this.radius, this.config.startAngle, this.config.endAngle ); const attrs = { d: pathData, fill: "none", stroke: type === "background" ? "#e0e0e0" : "#4CAF50", "stroke-width": this.config.strokeWidth, "stroke-linecap": "round", }; if (type === "background") { attrs.opacity = "0.3"; } return GaugeBase.createSVGElement("path", attrs); } /** * 진행률 업데이트 * @param {number} value - 값 * @param {boolean} animate - 애니메이션 적용 여부 */ update(value, animate = true) { const percent = GaugeBase.clamp(value / this.config.maxValue, 0, 1); const targetOffset = this.pathLength * (1 - percent); if (animate) { this._animateProgress(targetOffset); } else { this.path.style.strokeDashoffset = targetOffset; } } /** * 진행률 애니메이션 * @private */ _animateProgress(targetOffset) { const startOffset = parseFloat(this.path.style.strokeDashoffset) || this.pathLength; const startTime = performance.now(); const duration = this.config.animationDuration; const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easedProgress = GaugeBase.easing(progress, this.config.easing); const currentOffset = GaugeBase.lerp(startOffset, targetOffset, easedProgress); this.path.style.strokeDashoffset = currentOffset; if (progress < 1) { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); } /** * 색상 변경 * @param {string} color - 색상 */ setColor(color) { this.path.setAttribute("stroke", color); } /** * 리셋 */ reset() { this.path.style.strokeDashoffset = this.pathLength; } } /** * 선형 게이지 클래스 */ class LinearGauge extends GaugeBase { constructor(config = {}) { super({ width: 300, height: 20, ...config, }); } /** * 게이지 초기화 * @param {string|Element} container - 컨테이너 선택자 또는 요소 * @returns {HTMLElement} */ init(container) { const element = typeof container === "string" ? document.querySelector(container) : container; if (!element) { console.error("Container not found"); return null; } // 컨테이너 생성 this.container = document.createElement("div"); this.container.className = "linear-gauge"; this.container.style.cssText = ` width: ${this.config.width}px; height: ${this.config.height}px; background: #e0e0e0; border-radius: ${this.config.height / 2}px; overflow: hidden; position: relative; `; // 진행률 바 생성 this.bar = document.createElement("div"); this.bar.className = "gauge-bar"; this.bar.style.cssText = ` width: 0%; height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); transition: width ${this.config.animationDuration}ms ${this.config.easing}; `; this.container.appendChild(this.bar); element.appendChild(this.container); return this.container; } /** * 진행률 업데이트 * @param {number} value - 값 * @param {boolean} animate - 애니메이션 적용 여부 */ update(value, animate = true) { const percent = GaugeBase.clamp((value / this.config.maxValue) * 100, 0, 100); if (!animate) { this.bar.style.transition = "none"; void this.bar.offsetHeight; // 강제 리플로우 } this.bar.style.width = `${percent}%`; if (!animate) { // 다음 프레임에서 transition 복원 requestAnimationFrame(() => { this.bar.style.transition = `width ${this.config.animationDuration}ms ${this.config.easing}`; }); } } /** * 색상 변경 * @param {string} color - 색상 */ setColor(color) { this.bar.style.background = color; } /** * 리셋 */ reset() { this.bar.style.width = "0%"; } } // ES6 모듈 내보내기 if (typeof module !== "undefined" && module.exports) { module.exports = { GaugeBase, CircularGauge, LinearGauge }; }