Initial commit: 교육 프로젝트 배포
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
// ============================================
|
||||
// 게이지 차트 모듈 (이미지 아이콘 버전)
|
||||
// 공통 모듈 활용 (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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user