743 lines
21 KiB
JavaScript
743 lines
21 KiB
JavaScript
// ============================================
|
||
// 게이지 차트 모듈 (이미지 아이콘 버전)
|
||
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils)
|
||
// ============================================
|
||
|
||
class GaugeChart {
|
||
// 상수 정의
|
||
static CONSTANTS = {
|
||
MIN_PERCENT: 0.14, // 최소 표시 퍼센트 (14%)
|
||
ANIMATION_DURATION: 1400, // 애니메이션 시간 (ms)
|
||
PHASE2_DURATION: 800, // 초과 시 2단계 애니메이션 시간
|
||
ICON_HIDE_THRESHOLD: 0.7, // 아이콘 숨김 임계값 (70%)
|
||
};
|
||
|
||
static ICON_CONFIG = {
|
||
rocket: { width: 18, height: 48, offsetX: 9, offsetY: 24, outerOffset: 30 },
|
||
snail: {
|
||
width: 75, // 50 → 75 (1.5배)
|
||
height: 64.5, // 43 → 64.5 (1.5배)
|
||
offsetX: 37.5, // 25 → 37.5 (1.5배)
|
||
offsetY: 32.25, // 21.5 → 32.25 (1.5배)
|
||
outerOffset: 30, // 유지
|
||
},
|
||
};
|
||
|
||
constructor(config, dependencies = {}) {
|
||
// 의존성 주입 (폴백 포함)
|
||
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
|
||
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
|
||
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
|
||
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
|
||
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
|
||
|
||
// 이벤트 리스너 ID 저장 (정리용)
|
||
this.listenerIds = [];
|
||
|
||
try {
|
||
if (!config || typeof config !== 'object') {
|
||
this._handleError(new Error('config가 유효하지 않습니다.'), 'constructor');
|
||
config = {};
|
||
}
|
||
|
||
this.config = {
|
||
size: config.size || 832,
|
||
strokeWidth: config.strokeWidth || 31,
|
||
maxValue: config.maxValue || 50,
|
||
padding: config.padding || 20,
|
||
outerTextOffset: config.outerTextOffset || 6,
|
||
innerTextOffset: config.innerTextOffset || 35,
|
||
dotRadius: config.dotRadius || 7,
|
||
// 아이콘 이미지 경로 설정
|
||
rocketIconPath:
|
||
config.rocketIconPath || "/img/ico/icon-rocket.svg",
|
||
snailIconPath:
|
||
config.snailIconPath || "/img/ico/icon-snail.svg",
|
||
};
|
||
|
||
this.svg = null;
|
||
this.center = 0;
|
||
this.radius = 0;
|
||
this.animated = false;
|
||
} catch (error) {
|
||
this._handleError(error, 'constructor');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 에러 처리 헬퍼
|
||
* @private
|
||
*/
|
||
_handleError(error, context, additionalInfo = {}) {
|
||
if (this.errorHandler) {
|
||
this.errorHandler.handle(error, {
|
||
context: `GaugeChart.${context}`,
|
||
component: 'GaugeChart',
|
||
...additionalInfo
|
||
}, false);
|
||
} else {
|
||
console.error(`[GaugeChart] ${context}:`, error, additionalInfo);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// 초기화
|
||
// ============================================
|
||
|
||
init() {
|
||
try {
|
||
this.svg = this.domUtils?.$("#gauge") || document.getElementById("gauge");
|
||
if (!this.svg) {
|
||
this._handleError(new Error('게이지 SVG 요소를 찾을 수 없습니다.'), 'init');
|
||
return;
|
||
}
|
||
|
||
this._calculateDimensions();
|
||
this._setupViewBox();
|
||
} catch (error) {
|
||
this._handleError(error, 'init');
|
||
}
|
||
}
|
||
|
||
_calculateDimensions() {
|
||
try {
|
||
const { size, strokeWidth, padding } = this.config;
|
||
if (typeof size !== 'number' || typeof strokeWidth !== 'number' || typeof padding !== 'number') {
|
||
this._handleError(new Error('config 값이 유효하지 않습니다.'), '_calculateDimensions');
|
||
return;
|
||
}
|
||
|
||
this.center = size / 2;
|
||
this.radius = size / 2 - strokeWidth / 2 - padding;
|
||
} catch (error) {
|
||
this._handleError(error, '_calculateDimensions');
|
||
}
|
||
}
|
||
|
||
_setupViewBox() {
|
||
try {
|
||
if (!this.svg) {
|
||
this._handleError(new Error('svg 요소가 없습니다.'), '_setupViewBox');
|
||
return;
|
||
}
|
||
|
||
const { size } = this.config;
|
||
if (typeof size !== 'number') {
|
||
this._handleError(new Error('size가 유효하지 않습니다.'), '_setupViewBox');
|
||
return;
|
||
}
|
||
|
||
const extraSpace = 100;
|
||
this.svg.setAttribute(
|
||
"viewBox",
|
||
`-50 -50 ${size + 100} ${size + extraSpace}`
|
||
);
|
||
this.svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||
} catch (error) {
|
||
this._handleError(error, '_setupViewBox');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// 게이지 업데이트
|
||
// ============================================
|
||
|
||
update(value, labelValue = value) {
|
||
try {
|
||
if (!this.svg) {
|
||
console.warn("[GaugeChart] svg 요소가 없습니다.");
|
||
return;
|
||
}
|
||
|
||
// 입력값 유효성 검증
|
||
if (typeof value !== 'number' || isNaN(value) || value < 0) {
|
||
this._handleError(new Error(`유효하지 않은 value: ${value}`), 'update');
|
||
return;
|
||
}
|
||
|
||
const shouldAnimate = !this.animated;
|
||
this.svg.innerHTML = "";
|
||
|
||
const { percent, angle } = this._calculateAngle(value);
|
||
const isOverMax = value > this.config.maxValue;
|
||
|
||
this._createTextPaths();
|
||
this._createBackgroundArc(isOverMax);
|
||
this._createFilledArc(angle, isOverMax, shouldAnimate);
|
||
this._addMyLearningText(value, labelValue, percent, angle, shouldAnimate);
|
||
this._addEndIcon(angle, value, shouldAnimate);
|
||
this._addAverageLearningText(value, percent);
|
||
|
||
if (shouldAnimate) {
|
||
this.animated = true;
|
||
}
|
||
} catch (error) {
|
||
this._handleError(error, 'update', { value });
|
||
}
|
||
}
|
||
|
||
_calculateAngle(value) {
|
||
const { maxValue } = this.config;
|
||
const { MIN_PERCENT } = GaugeChart.CONSTANTS;
|
||
|
||
let percent;
|
||
|
||
if (value <= maxValue) {
|
||
percent = value / maxValue;
|
||
percent = Math.max(percent, MIN_PERCENT);
|
||
} else {
|
||
percent = 2 - value / maxValue;
|
||
percent = Math.max(percent, 0.1);
|
||
}
|
||
|
||
const angle = -180 + percent * 180;
|
||
|
||
return { percent, angle };
|
||
}
|
||
|
||
// ============================================
|
||
// SVG 생성 메서드
|
||
// ============================================
|
||
|
||
_createTextPaths() {
|
||
const defs = this._createSVGElement("defs");
|
||
|
||
// 외곽 텍스트 경로
|
||
const outerArc = this._createPathElement(
|
||
"outerArc",
|
||
this._createArcPath(this.radius - this.config.outerTextOffset, -180, 0)
|
||
);
|
||
defs.appendChild(outerArc);
|
||
|
||
// 내부 텍스트 경로
|
||
const innerArc = this._createPathElement(
|
||
"innerArc",
|
||
this._createArcPath(this.radius - this.config.innerTextOffset, -180, 0)
|
||
);
|
||
defs.appendChild(innerArc);
|
||
|
||
this.svg.appendChild(defs);
|
||
}
|
||
|
||
_createBackgroundArc(isOverMax) {
|
||
const bg = this._createPathElement(
|
||
null,
|
||
this._createArcPath(this.radius, -180, 0)
|
||
);
|
||
|
||
if (isOverMax) {
|
||
bg.setAttribute("stroke", "#D8823B");
|
||
} else {
|
||
bg.setAttribute("class", "bg-arc");
|
||
bg.setAttribute("stroke", "#72451F");
|
||
bg.setAttribute("stroke-opacity", "0.1");
|
||
}
|
||
|
||
bg.setAttribute("stroke-width", this.config.strokeWidth);
|
||
bg.setAttribute("stroke-linecap", "round");
|
||
bg.setAttribute("fill", "none");
|
||
|
||
this.svg.appendChild(bg);
|
||
}
|
||
|
||
_createFilledArc(angle, isOverMax, shouldAnimate) {
|
||
const gradient = this._createGradient(angle);
|
||
const fg = this._createPathElement(null, "");
|
||
|
||
fg.setAttribute("stroke", isOverMax ? "url(#arcSweepGradient)" : "#e89555");
|
||
fg.setAttribute("stroke-width", this.config.strokeWidth);
|
||
fg.setAttribute("stroke-linecap", "round");
|
||
fg.setAttribute("fill", "none");
|
||
|
||
this.svg.appendChild(fg);
|
||
|
||
if (shouldAnimate) {
|
||
this._animateFilledArc(fg, angle, isOverMax);
|
||
} else {
|
||
fg.setAttribute("d", this._createArcPath(this.radius, -180, angle));
|
||
}
|
||
}
|
||
|
||
_createGradient(angle) {
|
||
try {
|
||
if (!this.svg) {
|
||
this._handleError(new Error('svg 요소가 없습니다.'), '_createGradient');
|
||
return null;
|
||
}
|
||
|
||
const defs = this.domUtils?.$("defs", this.svg) || this.svg.querySelector("defs");
|
||
if (!defs) {
|
||
this._handleError(new Error('defs 요소를 찾을 수 없습니다.'), '_createGradient');
|
||
return null;
|
||
}
|
||
|
||
let gradient = this.domUtils?.$("#arcSweepGradient", defs) || defs.querySelector("#arcSweepGradient");
|
||
|
||
if (!gradient) {
|
||
gradient = this._createSVGElement("linearGradient");
|
||
gradient.setAttribute("id", "arcSweepGradient");
|
||
gradient.setAttribute("x1", "0%");
|
||
gradient.setAttribute("y1", "0%");
|
||
gradient.setAttribute("x2", "100%");
|
||
gradient.setAttribute("y2", "0%");
|
||
|
||
const colors = [
|
||
{ offset: "0%", color: "#72451F" },
|
||
{ offset: "100%", color: "#D8823B" },
|
||
];
|
||
|
||
colors.forEach(({ offset, color }) => {
|
||
const stop = this._createSVGElement("stop");
|
||
stop.setAttribute("offset", offset);
|
||
stop.setAttribute("stop-color", color);
|
||
gradient.appendChild(stop);
|
||
});
|
||
|
||
defs.appendChild(gradient);
|
||
}
|
||
|
||
gradient.setAttribute(
|
||
"gradientTransform",
|
||
`rotate(${angle - 90}, 0.5, 0.5)`
|
||
);
|
||
|
||
return gradient;
|
||
} catch (error) {
|
||
this._handleError(error, '_createGradient', { angle });
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// 애니메이션
|
||
// ============================================
|
||
|
||
_animateFilledArc(element, targetAngle, isOverMax) {
|
||
const { MIN_PERCENT, ANIMATION_DURATION, PHASE2_DURATION } =
|
||
GaugeChart.CONSTANTS;
|
||
const startAngle = -180 + MIN_PERCENT * 180;
|
||
const fullAngle = 0;
|
||
|
||
element.setAttribute(
|
||
"d",
|
||
this._createArcPath(this.radius, -180, startAngle)
|
||
);
|
||
|
||
if (isOverMax) {
|
||
this._animateTwoPhase(
|
||
element,
|
||
startAngle,
|
||
fullAngle,
|
||
targetAngle,
|
||
ANIMATION_DURATION,
|
||
PHASE2_DURATION
|
||
);
|
||
} else {
|
||
this._animateSinglePhase(
|
||
element,
|
||
startAngle,
|
||
targetAngle,
|
||
ANIMATION_DURATION
|
||
);
|
||
}
|
||
}
|
||
|
||
_animateSinglePhase(element, startAngle, endAngle, duration) {
|
||
const startTime = performance.now();
|
||
|
||
const animate = (currentTime) => {
|
||
const elapsed = currentTime - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
const easeOut = this._easeOutCubic(progress);
|
||
|
||
const currentAngle = startAngle + (endAngle - startAngle) * easeOut;
|
||
element.setAttribute(
|
||
"d",
|
||
this._createArcPath(this.radius, -180, currentAngle)
|
||
);
|
||
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate);
|
||
}
|
||
};
|
||
|
||
requestAnimationFrame(animate);
|
||
}
|
||
|
||
_animateTwoPhase(
|
||
element,
|
||
startAngle,
|
||
midAngle,
|
||
endAngle,
|
||
phase1Duration,
|
||
phase2Duration
|
||
) {
|
||
const startTime = performance.now();
|
||
|
||
const animate = (currentTime) => {
|
||
const elapsed = currentTime - startTime;
|
||
|
||
if (elapsed < phase1Duration) {
|
||
// Phase 1: 15% → 100%
|
||
const progress = elapsed / phase1Duration;
|
||
const easeOut = this._easeOutCubic(progress);
|
||
const currentAngle = startAngle + (midAngle - startAngle) * easeOut;
|
||
element.setAttribute(
|
||
"d",
|
||
this._createArcPath(this.radius, -180, currentAngle)
|
||
);
|
||
requestAnimationFrame(animate);
|
||
} else if (elapsed < phase1Duration + phase2Duration) {
|
||
// Phase 2: 100% → 최종값
|
||
const progress = (elapsed - phase1Duration) / phase2Duration;
|
||
const easeOut = this._easeOutCubic(progress);
|
||
const currentAngle = midAngle + (endAngle - midAngle) * easeOut;
|
||
element.setAttribute(
|
||
"d",
|
||
this._createArcPath(this.radius, -180, currentAngle)
|
||
);
|
||
requestAnimationFrame(animate);
|
||
} else {
|
||
// 완료
|
||
element.setAttribute(
|
||
"d",
|
||
this._createArcPath(this.radius, -180, endAngle)
|
||
);
|
||
}
|
||
};
|
||
|
||
requestAnimationFrame(animate);
|
||
}
|
||
|
||
// ============================================
|
||
// 텍스트 및 아이콘
|
||
// ============================================
|
||
|
||
_addMyLearningText(value, labelValue, percent, angle, shouldAnimate) {
|
||
try {
|
||
if (!this.svg) {
|
||
this._handleError(new Error('svg 요소가 없습니다.'), '_addMyLearningText');
|
||
return;
|
||
}
|
||
|
||
const text = this._createTextElement(
|
||
"나의 학습",
|
||
labelValue,
|
||
percent,
|
||
this.config.maxValue
|
||
);
|
||
|
||
if (!text) {
|
||
this._handleError(new Error('text 요소를 생성할 수 없습니다.'), '_addMyLearningText');
|
||
return;
|
||
}
|
||
|
||
this.svg.appendChild(text);
|
||
|
||
const textPath = this.domUtils?.$("textPath", text) || text.querySelector("textPath");
|
||
if (!textPath) {
|
||
this._handleError(new Error('textPath 요소를 찾을 수 없습니다.'), '_addMyLearningText');
|
||
return;
|
||
}
|
||
|
||
const finalOffset = this._calculateTextOffset(value, percent);
|
||
|
||
if (shouldAnimate) {
|
||
this._animateText(
|
||
textPath,
|
||
15,
|
||
finalOffset,
|
||
GaugeChart.CONSTANTS.ANIMATION_DURATION
|
||
);
|
||
} else {
|
||
textPath.setAttribute("startOffset", `${finalOffset}%`);
|
||
}
|
||
|
||
// 클릭 이벤트 추가: mypage.html로 이동
|
||
if (this.domUtils) {
|
||
this.domUtils.setStyles(text, { cursor: "pointer" });
|
||
} else {
|
||
text.style.cursor = "pointer";
|
||
}
|
||
|
||
const clickHandler = () => {
|
||
try {
|
||
window.location.href = "mypage.php";
|
||
} catch (error) {
|
||
this._handleError(error, '_addMyLearningText.clickHandler');
|
||
}
|
||
};
|
||
|
||
if (this.eventManager) {
|
||
const listenerId = this.eventManager.on(text, "click", clickHandler);
|
||
this.listenerIds.push({ element: text, id: listenerId, type: 'click' });
|
||
} else {
|
||
text.addEventListener("click", clickHandler);
|
||
}
|
||
} catch (error) {
|
||
this._handleError(error, '_addMyLearningText', { value, labelValue, percent, angle, shouldAnimate });
|
||
}
|
||
}
|
||
|
||
_createTextElement(label, value, percent, maxValue) {
|
||
const text = this._createSVGElement("text");
|
||
text.setAttribute("class", "my-learning-text");
|
||
text.setAttribute("font-size", "16");
|
||
text.setAttribute("font-weight", "700");
|
||
text.setAttribute("fill", "#FFF");
|
||
text.setAttribute("stroke", "rgba(0, 0, 0, 0.30)");
|
||
text.setAttribute("stroke-width", "2");
|
||
text.setAttribute("paint-order", "stroke fill");
|
||
|
||
const textPath = this._createSVGElement("textPath");
|
||
textPath.setAttributeNS(
|
||
"http://www.w3.org/1999/xlink",
|
||
"xlink:href",
|
||
"#outerArc"
|
||
);
|
||
textPath.setAttribute("text-anchor", "middle");
|
||
textPath.textContent = `${label} : ${value}분 >`;
|
||
|
||
text.appendChild(textPath);
|
||
return text;
|
||
}
|
||
|
||
_calculateTextOffset(value, percent) {
|
||
if (value > this.config.maxValue) {
|
||
return 95;
|
||
}
|
||
return Math.max(10, percent * 100 - 4.5);
|
||
}
|
||
|
||
_animateText(element, startOffset, endOffset, duration) {
|
||
const startTime = performance.now();
|
||
|
||
const animate = (currentTime) => {
|
||
const elapsed = currentTime - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
const easeOut = this._easeOutCubic(progress);
|
||
const currentOffset = startOffset + (endOffset - startOffset) * easeOut;
|
||
|
||
element.setAttribute("startOffset", `${currentOffset}%`);
|
||
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate);
|
||
}
|
||
};
|
||
|
||
requestAnimationFrame(animate);
|
||
}
|
||
|
||
_addEndIcon(angle, value, shouldAnimate) {
|
||
const { ICON_HIDE_THRESHOLD } = GaugeChart.CONSTANTS;
|
||
|
||
// 70%~100% 사이에는 아이콘 표시하지 않음
|
||
if (
|
||
value >= this.config.maxValue * ICON_HIDE_THRESHOLD &&
|
||
value <= this.config.maxValue
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const isOverMax = value > this.config.maxValue;
|
||
const iconType = isOverMax ? "rocket" : "snail";
|
||
const iconConfig = GaugeChart.ICON_CONFIG[iconType];
|
||
|
||
const icon = this._createIconElement(iconType);
|
||
this.svg.appendChild(icon);
|
||
|
||
if (shouldAnimate) {
|
||
this._animateIcon(icon, angle, iconConfig, isOverMax);
|
||
} else {
|
||
this._positionIcon(icon, angle, iconConfig, 0);
|
||
}
|
||
}
|
||
|
||
_createIconElement(type) {
|
||
const icon = this._createSVGElement("g");
|
||
|
||
// image 요소 생성
|
||
const image = this._createSVGElement("image");
|
||
const iconConfig = GaugeChart.ICON_CONFIG[type];
|
||
|
||
// 이미지 경로 설정
|
||
const imagePath =
|
||
type === "rocket"
|
||
? this.config.rocketIconPath
|
||
: this.config.snailIconPath;
|
||
|
||
image.setAttributeNS(
|
||
"http://www.w3.org/1999/xlink",
|
||
"xlink:href",
|
||
imagePath
|
||
);
|
||
image.setAttribute("width", iconConfig.width);
|
||
image.setAttribute("height", iconConfig.height);
|
||
|
||
icon.appendChild(image);
|
||
return icon;
|
||
}
|
||
|
||
_animateIcon(icon, targetAngle, iconConfig, isOverMax) {
|
||
const { MIN_PERCENT, ANIMATION_DURATION } = GaugeChart.CONSTANTS;
|
||
const startAngle = -180 + MIN_PERCENT * 180;
|
||
const fullAngle = 0;
|
||
const angleOffset = -3;
|
||
|
||
this._positionIcon(icon, startAngle, iconConfig, angleOffset);
|
||
|
||
if (isOverMax) {
|
||
// 초과 시: 100%까지만
|
||
this._animateIconPosition(
|
||
icon,
|
||
startAngle,
|
||
fullAngle,
|
||
iconConfig,
|
||
angleOffset,
|
||
ANIMATION_DURATION
|
||
);
|
||
} else {
|
||
// 정상 범위: 최종 각도까지
|
||
this._animateIconPosition(
|
||
icon,
|
||
startAngle,
|
||
targetAngle,
|
||
iconConfig,
|
||
angleOffset,
|
||
ANIMATION_DURATION
|
||
);
|
||
}
|
||
}
|
||
|
||
_animateIconPosition(
|
||
icon,
|
||
startAngle,
|
||
endAngle,
|
||
iconConfig,
|
||
angleOffset,
|
||
duration
|
||
) {
|
||
const startTime = performance.now();
|
||
|
||
const animate = (currentTime) => {
|
||
const elapsed = currentTime - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
const easeOut = this._easeOutCubic(progress);
|
||
|
||
const currentAngle = startAngle + (endAngle - startAngle) * easeOut;
|
||
this._positionIcon(icon, currentAngle, iconConfig, angleOffset);
|
||
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate);
|
||
}
|
||
};
|
||
|
||
requestAnimationFrame(animate);
|
||
}
|
||
|
||
_positionIcon(icon, angle, iconConfig, angleOffset = 0) {
|
||
const { offsetX, offsetY, outerOffset } = iconConfig;
|
||
const position = this._polarToCartesian(
|
||
this.center,
|
||
this.center,
|
||
this.radius + outerOffset,
|
||
angle + angleOffset
|
||
);
|
||
|
||
const rotation =
|
||
angle + (iconConfig === GaugeChart.ICON_CONFIG.snail ? 90 : 0);
|
||
|
||
icon.setAttribute(
|
||
"transform",
|
||
`translate(${position.x - offsetX}, ${position.y - offsetY}) rotate(${rotation}, ${offsetX}, ${offsetY})`
|
||
);
|
||
}
|
||
|
||
_addAverageLearningText(value, percent) {
|
||
const text = this._createSVGElement("text");
|
||
text.setAttribute("font-size", "13");
|
||
text.setAttribute("font-weight", "400");
|
||
text.setAttribute("fill", "#4A4947");
|
||
|
||
const textPath = this._createSVGElement("textPath");
|
||
textPath.setAttributeNS(
|
||
"http://www.w3.org/1999/xlink",
|
||
"xlink:href",
|
||
"#innerArc"
|
||
);
|
||
|
||
const offset =
|
||
value > this.config.maxValue ? Math.max(10, percent * 100) : 100;
|
||
const avgMinutes = Math.max(0, Math.round(Number(this.config.maxValue) || 0));
|
||
|
||
textPath.setAttribute("startOffset", `${offset}%`);
|
||
textPath.setAttribute("text-anchor", "end");
|
||
textPath.textContent = `전체 평균 학습 : ${avgMinutes.toLocaleString("ko-KR")}분`;
|
||
|
||
text.appendChild(textPath);
|
||
this.svg.appendChild(text);
|
||
}
|
||
|
||
// ============================================
|
||
// 유틸리티 메서드
|
||
// ============================================
|
||
|
||
_createSVGElement(type) {
|
||
return document.createElementNS("http://www.w3.org/2000/svg", type);
|
||
}
|
||
|
||
_createPathElement(id, d) {
|
||
const path = this._createSVGElement("path");
|
||
if (id) path.setAttribute("id", id);
|
||
if (d) path.setAttribute("d", d);
|
||
return path;
|
||
}
|
||
|
||
_polarToCartesian(cx, cy, r, angle) {
|
||
const rad = (angle * Math.PI) / 180;
|
||
return {
|
||
x: cx + r * Math.cos(rad),
|
||
y: cy + r * Math.sin(rad),
|
||
};
|
||
}
|
||
|
||
_createArcPath(r, startAngle, endAngle) {
|
||
const start = this._polarToCartesian(
|
||
this.center,
|
||
this.center,
|
||
r,
|
||
startAngle
|
||
);
|
||
const end = this._polarToCartesian(this.center, this.center, r, endAngle);
|
||
return `M ${start.x} ${start.y} A ${r} ${r} 0 0 1 ${end.x} ${end.y}`;
|
||
}
|
||
|
||
_easeOutCubic(t) {
|
||
return 1 - Math.pow(1 - t, 3);
|
||
}
|
||
|
||
/**
|
||
* 리소스 정리 (이벤트 리스너 제거)
|
||
*/
|
||
destroy() {
|
||
try {
|
||
// 이벤트 리스너 제거
|
||
if (this.eventManager && this.listenerIds.length > 0) {
|
||
this.listenerIds.forEach(({ element, id }) => {
|
||
this.eventManager.off(element, id);
|
||
});
|
||
this.listenerIds = [];
|
||
}
|
||
|
||
// 참조 정리
|
||
this.svg = null;
|
||
this.config = null;
|
||
this.center = 0;
|
||
this.radius = 0;
|
||
this.animated = false;
|
||
} catch (error) {
|
||
this._handleError(error, 'destroy');
|
||
}
|
||
}
|
||
}
|