Initial commit: 교육 프로젝트 배포
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* 애니메이션 유틸리티 모듈 (AnimationUtils.js)
|
||||
* ========================================
|
||||
* 페이드, 슬라이드, 카운트업 등 애니메이션을 제공합니다.
|
||||
*
|
||||
* [초보자용] AnimationUtils.fade(el, 'in', 300) / AnimationUtils.fade(el, 'out', 300)
|
||||
*
|
||||
* @module AnimationUtils
|
||||
*/
|
||||
class AnimationUtils {
|
||||
/**
|
||||
* 순차적 요소 애니메이션
|
||||
* @param {Array|NodeList} elements - 요소 배열
|
||||
* @param {string} className - 추가할 클래스
|
||||
* @param {number} delay - 각 요소 간 지연 시간 (ms)
|
||||
* @param {Function} callback - 각 요소 애니메이션 후 콜백
|
||||
*/
|
||||
static async sequentialAnimate(elements, className = "show", delay = 50, callback = null) {
|
||||
const items = Array.isArray(elements) ? elements : Array.from(elements);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
items[i].classList.add(className);
|
||||
if (callback) callback(items[i], i);
|
||||
resolve();
|
||||
}, delay * i);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 페이드 인/아웃
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {string} type - "in" 또는 "out"
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async fade(element, type = "in", duration = 300) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 기존 display 값 저장 (grid, flex 등 유지)
|
||||
const originalDisplay = element.style.display || window.getComputedStyle(element).display;
|
||||
const isGridOrFlex = originalDisplay === "grid" || originalDisplay === "flex" ||
|
||||
originalDisplay.includes("grid") || originalDisplay.includes("flex");
|
||||
|
||||
if (type === "in") {
|
||||
// grid/flex인 경우 display를 설정하지 않음
|
||||
if (!isGridOrFlex) {
|
||||
element.style.display = "block";
|
||||
}
|
||||
element.style.opacity = "0";
|
||||
element.style.transition = `opacity ${duration}ms ease`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
element.style.transition = "";
|
||||
// grid/flex인 경우 display 스타일 제거
|
||||
if (isGridOrFlex) {
|
||||
element.style.display = "";
|
||||
}
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
} else {
|
||||
element.style.opacity = "1";
|
||||
element.style.transition = `opacity ${duration}ms ease`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.style.opacity = "0";
|
||||
setTimeout(() => {
|
||||
// grid/flex인 경우 display를 none으로 설정하지 않음
|
||||
if (!isGridOrFlex) {
|
||||
element.style.display = "none";
|
||||
}
|
||||
element.style.transition = "";
|
||||
element.style.opacity = ""; // 재오픈 시 opacity 초기화
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 슬라이드
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {string} type - "up", "down", "left", "right"
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async slide(element, type = "down", duration = 300) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const isShow = type === "down" || type === "right";
|
||||
const property = type === "up" || type === "down" ? "height" : "width";
|
||||
const overflow = element.style.overflow;
|
||||
|
||||
element.style.overflow = "hidden";
|
||||
|
||||
if (isShow) {
|
||||
element.style.display = "block";
|
||||
const size = element[property === "height" ? "scrollHeight" : "scrollWidth"];
|
||||
element.style[property] = "0";
|
||||
element.style.transition = `${property} ${duration}ms ease`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.style[property] = `${size}px`;
|
||||
setTimeout(() => {
|
||||
element.style[property] = "";
|
||||
element.style.overflow = overflow;
|
||||
element.style.transition = "";
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
} else {
|
||||
const size = element[property === "height" ? "offsetHeight" : "offsetWidth"];
|
||||
element.style[property] = `${size}px`;
|
||||
element.style.transition = `${property} ${duration}ms ease`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.style[property] = "0";
|
||||
setTimeout(() => {
|
||||
element.style.display = "none";
|
||||
element.style[property] = "";
|
||||
element.style.overflow = overflow;
|
||||
element.style.transition = "";
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 카운팅 애니메이션
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} start - 시작 값
|
||||
* @param {number} end - 종료 값
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @param {Function} format - 포맷 함수
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async countUp(element, start, end, duration = 1000, format = null) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startTime = performance.now();
|
||||
const range = end - start;
|
||||
|
||||
const update = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-out 효과
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const current = start + range * easeProgress;
|
||||
|
||||
element.textContent = format ? format(current) : Math.round(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update);
|
||||
} else {
|
||||
element.textContent = format ? format(end) : end;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(update);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크롤 애니메이션
|
||||
* @param {Element|string} target - 대상 요소 또는 선택자
|
||||
* @param {Object} options - 옵션
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async scrollTo(target, options = {}) {
|
||||
const {
|
||||
duration = 500,
|
||||
offset = 0,
|
||||
easing = "ease-in-out",
|
||||
container = window,
|
||||
} = options;
|
||||
|
||||
const element = typeof target === "string" ? document.querySelector(target) : target;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const targetPosition =
|
||||
element.getBoundingClientRect().top +
|
||||
(container === window ? window.pageYOffset : container.scrollTop) +
|
||||
offset;
|
||||
|
||||
const startPosition = container === window ? window.pageYOffset : container.scrollTop;
|
||||
const distance = targetPosition - startPosition;
|
||||
const startTime = performance.now();
|
||||
|
||||
const easingFunctions = {
|
||||
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),
|
||||
};
|
||||
|
||||
const easingFunc = easingFunctions[easing] || easingFunctions["ease-in-out"];
|
||||
|
||||
const animation = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easedProgress = easingFunc(progress);
|
||||
|
||||
const position = startPosition + distance * easedProgress;
|
||||
|
||||
if (container === window) {
|
||||
window.scrollTo(0, position);
|
||||
} else {
|
||||
container.scrollTop = position;
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animation);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animation);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 흔들기 애니메이션
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} intensity - 강도
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async shake(element, intensity = 5, duration = 500) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startTime = performance.now();
|
||||
const originalTransform = element.style.transform;
|
||||
|
||||
const animation = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const x = Math.sin(progress * Math.PI * 4) * intensity * (1 - progress);
|
||||
element.style.transform = `translateX(${x}px)`;
|
||||
requestAnimationFrame(animation);
|
||||
} else {
|
||||
element.style.transform = originalTransform;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animation);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 펄스 애니메이션
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} scale - 스케일
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async pulse(element, scale = 1.1, duration = 500) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const originalTransform = element.style.transform;
|
||||
element.style.transition = `transform ${duration / 2}ms ease-in-out`;
|
||||
|
||||
element.style.transform = `scale(${scale})`;
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.transform = originalTransform;
|
||||
setTimeout(() => {
|
||||
element.style.transition = "";
|
||||
resolve();
|
||||
}, duration / 2);
|
||||
}, duration / 2);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 바운스 애니메이션
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} height - 바운스 높이
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async bounce(element, height = 20, duration = 600) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startTime = performance.now();
|
||||
const originalTransform = element.style.transform;
|
||||
|
||||
const animation = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const bounceProgress = Math.sin(progress * Math.PI);
|
||||
const y = -height * bounceProgress;
|
||||
element.style.transform = `translateY(${y}px)`;
|
||||
requestAnimationFrame(animation);
|
||||
} else {
|
||||
element.style.transform = originalTransform;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animation);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 회전 애니메이션
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} degrees - 회전 각도
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async rotate(element, degrees = 360, duration = 500) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
element.style.transition = `transform ${duration}ms ease`;
|
||||
element.style.transform = `rotate(${degrees}deg)`;
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.transition = "";
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 타이핑 효과
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {string} text - 타이핑할 텍스트
|
||||
* @param {number} speed - 타이핑 속도 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async typing(element, text, speed = 50) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let index = 0;
|
||||
element.textContent = "";
|
||||
|
||||
const type = () => {
|
||||
if (index < text.length) {
|
||||
element.textContent += text.charAt(index);
|
||||
index++;
|
||||
setTimeout(type, speed);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
type();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로그레스 바 애니메이션
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} percent - 진행률 (0-100)
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async progressBar(element, percent, duration = 500) {
|
||||
if (!element) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
element.style.transition = `width ${duration}ms ease-out`;
|
||||
element.style.width = `${percent}%`;
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.transition = "";
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 파티클 효과
|
||||
* @param {Element} container - 컨테이너 요소
|
||||
* @param {Object} options - 옵션
|
||||
*/
|
||||
static particles(container, options = {}) {
|
||||
const {
|
||||
count = 30,
|
||||
color = "#4CAF50",
|
||||
size = 5,
|
||||
duration = 2000,
|
||||
spread = 100,
|
||||
} = options;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const particle = document.createElement("div");
|
||||
particle.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
background: ${color};
|
||||
border-radius: 50%;
|
||||
left: ${centerX}px;
|
||||
top: ${centerY}px;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
container.appendChild(particle);
|
||||
|
||||
const angle = (Math.PI * 2 * i) / count;
|
||||
const velocity = spread * (0.5 + Math.random() * 0.5);
|
||||
const x = Math.cos(angle) * velocity;
|
||||
const y = Math.sin(angle) * velocity;
|
||||
|
||||
particle.animate(
|
||||
[
|
||||
{ transform: "translate(0, 0) scale(1)", opacity: 1 },
|
||||
{ transform: `translate(${x}px, ${y}px) scale(0)`, opacity: 0 },
|
||||
],
|
||||
{
|
||||
duration: duration,
|
||||
easing: "cubic-bezier(0, 0.5, 0.5, 1)",
|
||||
}
|
||||
).onfinish = () => {
|
||||
particle.remove();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리플 효과
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {Event} event - 클릭 이벤트
|
||||
* @param {Object} options - 옵션
|
||||
*/
|
||||
static ripple(element, event, options = {}) {
|
||||
const { color = "rgba(255, 255, 255, 0.6)", duration = 600 } = options;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const x = event.clientX - rect.left - size / 2;
|
||||
const y = event.clientY - rect.top - size / 2;
|
||||
|
||||
const ripple = document.createElement("span");
|
||||
ripple.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
background: ${color};
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
transform: scale(0);
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
// 상대 위치 설정
|
||||
const position = window.getComputedStyle(element).position;
|
||||
if (position !== "relative" && position !== "absolute") {
|
||||
element.style.position = "relative";
|
||||
}
|
||||
|
||||
element.style.overflow = "hidden";
|
||||
element.appendChild(ripple);
|
||||
|
||||
ripple.animate(
|
||||
[
|
||||
{ transform: "scale(0)", opacity: 1 },
|
||||
{ transform: "scale(2)", opacity: 0 },
|
||||
],
|
||||
{
|
||||
duration: duration,
|
||||
easing: "ease-out",
|
||||
}
|
||||
).onfinish = () => {
|
||||
ripple.remove();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = AnimationUtils;
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* 설정 관리 모듈
|
||||
* @module ConfigManager
|
||||
*/
|
||||
class ConfigManager {
|
||||
constructor(defaults = {}) {
|
||||
this.defaults = defaults;
|
||||
this.config = { ...defaults };
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 값 가져오기
|
||||
* @param {string} key - 키 (점 표기법 지원)
|
||||
* @param {*} defaultValue - 기본값
|
||||
* @returns {*}
|
||||
*/
|
||||
get(key, defaultValue = null) {
|
||||
return this._getNestedValue(this.config, key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 값 설정
|
||||
* @param {string} key - 키 (점 표기법 지원)
|
||||
* @param {*} value - 값
|
||||
*/
|
||||
set(key, value) {
|
||||
this._setNestedValue(this.config, key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 설정 값 설정
|
||||
* @param {Object} config - 설정 객체
|
||||
*/
|
||||
setMultiple(config) {
|
||||
this.config = this._deepMerge(this.config, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 값 삭제
|
||||
* @param {string} key - 키
|
||||
*/
|
||||
remove(key) {
|
||||
this._deleteNestedValue(this.config, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 값 존재 확인
|
||||
* @param {string} key - 키
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
return this._getNestedValue(this.config, key) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 설정 가져오기
|
||||
* @returns {Object}
|
||||
*/
|
||||
getAll() {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 초기화
|
||||
*/
|
||||
reset() {
|
||||
this.config = { ...this.defaults };
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본값으로 병합
|
||||
* @param {Object} config - 설정 객체
|
||||
* @returns {Object}
|
||||
*/
|
||||
mergeWithDefaults(config) {
|
||||
return this._deepMerge({ ...this.defaults }, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열로 변환
|
||||
* @returns {string}
|
||||
*/
|
||||
toJSON() {
|
||||
return JSON.stringify(this.config, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열에서 로드
|
||||
* @param {string} json - JSON 문자열
|
||||
*/
|
||||
fromJSON(json) {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
this.config = this._deepMerge({ ...this.defaults }, parsed);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse JSON:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 스토리지에 저장
|
||||
* @param {string} key - 저장 키
|
||||
*/
|
||||
saveToStorage(key = "app_config") {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(this.config));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save to storage:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 스토리지에서 로드
|
||||
* @param {string} key - 저장 키
|
||||
*/
|
||||
loadFromStorage(key = "app_config") {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
this.config = this._deepMerge({ ...this.defaults }, parsed);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Failed to load from storage:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체 값 가져오기
|
||||
* @private
|
||||
*/
|
||||
_getNestedValue(obj, key, defaultValue = null) {
|
||||
const keys = key.split(".");
|
||||
let value = obj;
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === "object" && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return value !== undefined ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체 값 설정
|
||||
* @private
|
||||
*/
|
||||
_setNestedValue(obj, key, value) {
|
||||
const keys = key.split(".");
|
||||
const lastKey = keys.pop();
|
||||
let target = obj;
|
||||
|
||||
for (const k of keys) {
|
||||
if (!(k in target) || typeof target[k] !== "object") {
|
||||
target[k] = {};
|
||||
}
|
||||
target = target[k];
|
||||
}
|
||||
|
||||
target[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체 값 삭제
|
||||
* @private
|
||||
*/
|
||||
_deleteNestedValue(obj, key) {
|
||||
const keys = key.split(".");
|
||||
const lastKey = keys.pop();
|
||||
let target = obj;
|
||||
|
||||
for (const k of keys) {
|
||||
if (!(k in target) || typeof target[k] !== "object") {
|
||||
return;
|
||||
}
|
||||
target = target[k];
|
||||
}
|
||||
|
||||
delete target[lastKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* 깊은 병합
|
||||
* @private
|
||||
*/
|
||||
_deepMerge(target, source) {
|
||||
const output = { ...target };
|
||||
|
||||
if (this._isObject(target) && this._isObject(source)) {
|
||||
Object.keys(source).forEach((key) => {
|
||||
if (this._isObject(source[key])) {
|
||||
if (!(key in target)) {
|
||||
output[key] = source[key];
|
||||
} else {
|
||||
output[key] = this._deepMerge(target[key], source[key]);
|
||||
}
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 객체 확인
|
||||
* @private
|
||||
*/
|
||||
_isObject(item) {
|
||||
return item && typeof item === "object" && !Array.isArray(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 설정 관리 (싱글톤)
|
||||
*/
|
||||
class AppConfig extends ConfigManager {
|
||||
static instance = null;
|
||||
|
||||
constructor(defaults = {}) {
|
||||
if (AppConfig.instance) {
|
||||
return AppConfig.instance;
|
||||
}
|
||||
|
||||
super({
|
||||
app: {
|
||||
name: "My App",
|
||||
version: "1.0.0",
|
||||
debug: false,
|
||||
},
|
||||
ui: {
|
||||
theme: "light",
|
||||
language: "ko",
|
||||
animations: true,
|
||||
},
|
||||
features: {
|
||||
search: true,
|
||||
notifications: true,
|
||||
autoSave: true,
|
||||
},
|
||||
...defaults,
|
||||
});
|
||||
|
||||
AppConfig.instance = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 싱글톤 인스턴스 가져오기
|
||||
* @returns {AppConfig}
|
||||
*/
|
||||
static getInstance() {
|
||||
if (!AppConfig.instance) {
|
||||
AppConfig.instance = new AppConfig();
|
||||
}
|
||||
return AppConfig.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 이름 가져오기
|
||||
* @returns {string}
|
||||
*/
|
||||
getAppName() {
|
||||
return this.get("app.name");
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 버전 가져오기
|
||||
* @returns {string}
|
||||
*/
|
||||
getAppVersion() {
|
||||
return this.get("app.version");
|
||||
}
|
||||
|
||||
/**
|
||||
* 디버그 모드 확인
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isDebugMode() {
|
||||
return this.get("app.debug", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마 가져오기
|
||||
* @returns {string}
|
||||
*/
|
||||
getTheme() {
|
||||
return this.get("ui.theme", "light");
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마 설정
|
||||
* @param {string} theme - 테마
|
||||
*/
|
||||
setTheme(theme) {
|
||||
this.set("ui.theme", theme);
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 언어 가져오기
|
||||
* @returns {string}
|
||||
*/
|
||||
getLanguage() {
|
||||
return this.get("ui.language", "ko");
|
||||
}
|
||||
|
||||
/**
|
||||
* 언어 설정
|
||||
* @param {string} language - 언어
|
||||
*/
|
||||
setLanguage(language) {
|
||||
this.set("ui.language", language);
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 애니메이션 사용 여부
|
||||
* @returns {boolean}
|
||||
*/
|
||||
useAnimations() {
|
||||
return this.get("ui.animations", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 활성화 여부
|
||||
* @param {string} feature - 기능 이름
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isFeatureEnabled(feature) {
|
||||
return this.get(`features.${feature}`, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 토글
|
||||
* @param {string} feature - 기능 이름
|
||||
*/
|
||||
toggleFeature(feature) {
|
||||
const current = this.isFeatureEnabled(feature);
|
||||
this.set(`features.${feature}`, !current);
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML data 속성에서 설정 로드
|
||||
*/
|
||||
class DataAttributeConfig {
|
||||
/**
|
||||
* 요소에서 설정 로드
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {string} prefix - 속성 접두사
|
||||
* @returns {Object}
|
||||
*/
|
||||
static loadFromElement(element, prefix = "data-") {
|
||||
if (!element) return {};
|
||||
|
||||
const config = {};
|
||||
const attributes = element.attributes;
|
||||
|
||||
for (let i = 0; i < attributes.length; i++) {
|
||||
const attr = attributes[i];
|
||||
if (attr.name.startsWith(prefix)) {
|
||||
const key = attr.name
|
||||
.substring(prefix.length)
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
config[key] = this._parseValue(attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 파싱
|
||||
* @private
|
||||
*/
|
||||
static _parseValue(value) {
|
||||
// boolean
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
|
||||
// number
|
||||
if (!isNaN(value) && value !== "") {
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
// JSON
|
||||
if ((value.startsWith("{") || value.startsWith("[")) &&
|
||||
(value.endsWith("}") || value.endsWith("]"))) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패 시 문자열 반환
|
||||
}
|
||||
}
|
||||
|
||||
// string
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { ConfigManager, AppConfig, DataAttributeConfig };
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* DOM 조작 유틸리티 모듈 (DOMUtils.js)
|
||||
* ========================================
|
||||
* document.querySelector 대신 쓰는 간편 함수들입니다.
|
||||
*
|
||||
* [초보자용 사용 예]
|
||||
* DOMUtils.$('.my-class') → 첫 번째 요소 (querySelector)
|
||||
* DOMUtils.$$('.my-class') → 모든 요소 (querySelectorAll)
|
||||
* DOMUtils.fadeIn(el, 300) → 페이드 인 (300ms)
|
||||
* DOMUtils.fadeOut(el, 300) → 페이드 아웃
|
||||
* DOMUtils.delegate(parent, 'click', '.btn', handler) → 동적 요소에 이벤트 위임
|
||||
* DOMUtils.htmlToElement('<div>') → HTML 문자열 → Element
|
||||
*
|
||||
* @module DOMUtils
|
||||
*/
|
||||
class DOMUtils {
|
||||
/**
|
||||
* 요소 선택 (단일)
|
||||
* @param {string} selector - CSS 선택자
|
||||
* @param {Element} parent - 부모 요소
|
||||
* @returns {Element|null}
|
||||
*/
|
||||
static $(selector, parent = document) {
|
||||
return parent.querySelector(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 선택 (다중)
|
||||
* @param {string} selector - CSS 선택자
|
||||
* @param {Element} parent - 부모 요소
|
||||
* @returns {NodeList}
|
||||
*/
|
||||
static $$(selector, parent = document) {
|
||||
return parent.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 생성
|
||||
* @param {string} tag - 태그명
|
||||
* @param {Object} attrs - 속성 객체
|
||||
* @param {string} content - 내용
|
||||
* @returns {Element}
|
||||
*/
|
||||
static createElement(tag, attrs = {}, content = "") {
|
||||
const element = document.createElement(tag);
|
||||
|
||||
Object.entries(attrs).forEach(([key, value]) => {
|
||||
if (key === "class" || key === "className") {
|
||||
element.className = value;
|
||||
} else if (key === "style" && typeof value === "object") {
|
||||
Object.assign(element.style, value);
|
||||
} else if (key.startsWith("data-")) {
|
||||
element.setAttribute(key, value);
|
||||
} else {
|
||||
element[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (content) {
|
||||
if (typeof content === "string") {
|
||||
element.innerHTML = content;
|
||||
} else if (content instanceof Node) {
|
||||
element.appendChild(content);
|
||||
}
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클래스 토글
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {string} className - 클래스명
|
||||
* @param {boolean} force - 강제 적용 여부
|
||||
*/
|
||||
static toggleClass(element, className, force) {
|
||||
if (!element) return;
|
||||
if (force !== undefined) {
|
||||
element.classList.toggle(className, force);
|
||||
} else {
|
||||
element.classList.toggle(className);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 클래스 추가
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {...string} classNames - 클래스명들
|
||||
*/
|
||||
static addClasses(element, ...classNames) {
|
||||
if (!element) return;
|
||||
element.classList.add(...classNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 클래스 제거
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {...string} classNames - 클래스명들
|
||||
*/
|
||||
static removeClasses(element, ...classNames) {
|
||||
if (!element) return;
|
||||
element.classList.remove(...classNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소의 위치 정보 가져오기
|
||||
* @param {Element} element - 대상 요소
|
||||
* @returns {Object}
|
||||
*/
|
||||
static getPosition(element) {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소를 퍼센트 위치로 설정
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} x - X 위치 (%)
|
||||
* @param {number} y - Y 위치 (%)
|
||||
* @param {string} transform - 추가 transform
|
||||
*/
|
||||
static setPercentPosition(element, x, y, transform = "translate(-50%, -50%)") {
|
||||
if (!element) return;
|
||||
element.style.position = "absolute";
|
||||
element.style.left = `${x}%`;
|
||||
element.style.top = `${y}%`;
|
||||
if (transform) {
|
||||
element.style.transform = transform;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 페이드 인
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static fadeIn(element, duration = 300) {
|
||||
if (!element) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 기존 display 값 저장 (grid, flex 등 유지)
|
||||
const originalDisplay = element.style.display || window.getComputedStyle(element).display;
|
||||
const isGridOrFlex = originalDisplay === "grid" || originalDisplay === "flex" ||
|
||||
originalDisplay.includes("grid") || originalDisplay.includes("flex");
|
||||
|
||||
element.style.opacity = "0";
|
||||
// grid/flex인 경우 display를 설정하지 않음
|
||||
if (!isGridOrFlex) {
|
||||
element.style.display = "block";
|
||||
}
|
||||
element.style.transition = `opacity ${duration}ms ease-in-out`;
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.opacity = "1";
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.transition = "";
|
||||
// grid/flex인 경우 display 스타일 제거
|
||||
if (isGridOrFlex) {
|
||||
element.style.display = "";
|
||||
}
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 페이드 아웃
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static fadeOut(element, duration = 300) {
|
||||
if (!element) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
element.style.opacity = "1";
|
||||
element.style.transition = `opacity ${duration}ms ease-in-out`;
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.opacity = "0";
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.display = "none";
|
||||
element.style.transition = "";
|
||||
element.style.opacity = ""; // 재오픈 시 opacity 초기화
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 슬라이드 다운
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static slideDown(element, duration = 300) {
|
||||
if (!element) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
element.style.display = "block";
|
||||
const height = element.scrollHeight;
|
||||
element.style.height = "0";
|
||||
element.style.overflow = "hidden";
|
||||
element.style.transition = `height ${duration}ms ease-in-out`;
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.height = `${height}px`;
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.height = "";
|
||||
element.style.overflow = "";
|
||||
element.style.transition = "";
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 슬라이드 업
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {number} duration - 지속 시간 (ms)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static slideUp(element, duration = 300) {
|
||||
if (!element) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const height = element.scrollHeight;
|
||||
element.style.height = `${height}px`;
|
||||
element.style.overflow = "hidden";
|
||||
element.style.transition = `height ${duration}ms ease-in-out`;
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.height = "0";
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.display = "none";
|
||||
element.style.height = "";
|
||||
element.style.overflow = "";
|
||||
element.style.transition = "";
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 위임
|
||||
* @param {Element} parent - 부모 요소
|
||||
* @param {string} eventType - 이벤트 타입
|
||||
* @param {string} selector - 자식 선택자
|
||||
* @param {Function} handler - 핸들러 함수
|
||||
*/
|
||||
static delegate(parent, eventType, selector, handler) {
|
||||
if (!parent) return;
|
||||
|
||||
parent.addEventListener(eventType, (event) => {
|
||||
const target = event.target.closest(selector);
|
||||
if (target && parent.contains(target)) {
|
||||
handler.call(target, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 문자열을 요소로 변환
|
||||
* @param {string} html - HTML 문자열
|
||||
* @returns {Element}
|
||||
*/
|
||||
static htmlToElement(html) {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html.trim();
|
||||
return template.content.firstElementChild;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 문자열을 요소 배열로 변환
|
||||
* @param {string} html - HTML 문자열
|
||||
* @returns {Array}
|
||||
*/
|
||||
static htmlToElements(html) {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = html.trim();
|
||||
return Array.from(template.content.children);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소가 뷰포트에 있는지 확인
|
||||
* @param {Element} element - 대상 요소
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isInViewport(element) {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부드러운 스크롤
|
||||
* @param {Element|string} target - 대상 요소 또는 선택자
|
||||
* @param {Object} options - 옵션
|
||||
*/
|
||||
static smoothScroll(target, options = {}) {
|
||||
const element = typeof target === "string" ? this.$(target) : target;
|
||||
if (!element) return;
|
||||
|
||||
const defaultOptions = {
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
inline: "nearest",
|
||||
};
|
||||
|
||||
element.scrollIntoView({ ...defaultOptions, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 화면 토글
|
||||
* @param {Element} element - 대상 요소
|
||||
*/
|
||||
static toggleFullscreen(element = document.documentElement) {
|
||||
if (!document.fullscreenElement) {
|
||||
element.requestFullscreen?.() ||
|
||||
element.webkitRequestFullscreen?.() ||
|
||||
element.msRequestFullscreen?.();
|
||||
} else {
|
||||
document.exitFullscreen?.() ||
|
||||
document.webkitExitFullscreen?.() ||
|
||||
document.msExitFullscreen?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클립보드에 복사
|
||||
* @param {string} text - 복사할 텍스트
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소의 스타일 가져오기
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {string} property - CSS 속성
|
||||
* @returns {string}
|
||||
*/
|
||||
static getStyle(element, property) {
|
||||
if (!element) return null;
|
||||
return window.getComputedStyle(element).getPropertyValue(property);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 스타일 설정
|
||||
* @param {Element} element - 대상 요소
|
||||
* @param {Object} styles - 스타일 객체
|
||||
*/
|
||||
static setStyles(element, styles) {
|
||||
if (!element || !styles) return;
|
||||
Object.assign(element.style, styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 제거
|
||||
* @param {Element} element - 대상 요소
|
||||
*/
|
||||
static remove(element) {
|
||||
if (element && element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 내용 비우기
|
||||
* @param {Element} element - 대상 요소
|
||||
*/
|
||||
static empty(element) {
|
||||
if (!element) return;
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = DOMUtils;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 의존성 주입 컨테이너
|
||||
* 명확한 의존성 관리 및 테스트 용이성 향상
|
||||
* @module DependencyInjector
|
||||
*/
|
||||
class DependencyInjector {
|
||||
constructor() {
|
||||
this.services = new Map();
|
||||
this.singletons = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 등록
|
||||
* @param {string} name - 서비스 이름
|
||||
* @param {Function|Object} factory - 팩토리 함수 또는 인스턴스
|
||||
* @param {boolean} singleton - 싱글톤 여부
|
||||
*/
|
||||
register(name, factory, singleton = true) {
|
||||
if (typeof factory === 'function') {
|
||||
this.services.set(name, { factory, singleton });
|
||||
} else {
|
||||
// 이미 인스턴스인 경우
|
||||
this.singletons.set(name, factory);
|
||||
this.services.set(name, { factory: () => factory, singleton: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 가져오기
|
||||
* @param {string} name - 서비스 이름
|
||||
* @returns {*}
|
||||
*/
|
||||
get(name) {
|
||||
// 싱글톤 캐시 확인
|
||||
if (this.singletons.has(name)) {
|
||||
return this.singletons.get(name);
|
||||
}
|
||||
|
||||
const service = this.services.get(name);
|
||||
if (!service) {
|
||||
throw new Error(`Service "${name}" is not registered`);
|
||||
}
|
||||
|
||||
const instance = service.factory(this);
|
||||
|
||||
// 싱글톤인 경우 캐시
|
||||
if (service.singleton) {
|
||||
this.singletons.set(name, instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 존재 여부 확인
|
||||
* @param {string} name - 서비스 이름
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(name) {
|
||||
return this.services.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 제거
|
||||
* @param {string} name - 서비스 이름
|
||||
*/
|
||||
remove(name) {
|
||||
this.services.delete(name);
|
||||
this.singletons.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 서비스 초기화
|
||||
*/
|
||||
clear() {
|
||||
this.services.clear();
|
||||
this.singletons.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 서비스 한 번에 등록
|
||||
* @param {Object} services - 서비스 객체
|
||||
*/
|
||||
registerAll(services) {
|
||||
Object.entries(services).forEach(([name, factory]) => {
|
||||
this.register(name, factory);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 의존성 주입 컨테이너
|
||||
*/
|
||||
const di = new DependencyInjector();
|
||||
|
||||
// 기본 서비스 등록 (있는 경우)
|
||||
if (typeof DOMUtils !== 'undefined') {
|
||||
di.register('DOMUtils', () => DOMUtils, true);
|
||||
}
|
||||
if (typeof Utils !== 'undefined') {
|
||||
di.register('Utils', () => Utils, true);
|
||||
}
|
||||
if (typeof AnimationUtils !== 'undefined') {
|
||||
di.register('AnimationUtils', () => AnimationUtils, true);
|
||||
}
|
||||
if (typeof eventManager !== 'undefined') {
|
||||
di.register('eventManager', () => eventManager, true);
|
||||
}
|
||||
if (typeof ErrorHandler !== 'undefined') {
|
||||
di.register('ErrorHandler', () => ErrorHandler, true);
|
||||
}
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { DependencyInjector, di };
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 에러 처리 모듈 (ErrorHandler.js)
|
||||
* ========================================
|
||||
* 에러를 한곳에서 처리하고 로깅합니다.
|
||||
*
|
||||
* [초보자용]
|
||||
* ErrorHandler.safeExecute(() => 위험한함수(), 기본값)
|
||||
* ErrorHandler.handle(error, { context: '내모듈' })
|
||||
*
|
||||
* @module ErrorHandler
|
||||
*/
|
||||
class ErrorHandler {
|
||||
constructor() {
|
||||
this.errorLog = [];
|
||||
this.maxLogSize = 100;
|
||||
this.onErrorCallbacks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리
|
||||
* @param {Error|string} error - 에러 객체 또는 메시지
|
||||
* @param {Object} context - 컨텍스트 정보
|
||||
* @param {boolean} showToUser - 사용자에게 표시할지 여부
|
||||
*/
|
||||
static handle(error, context = {}, showToUser = false) {
|
||||
const errorInfo = this._normalizeError(error, context);
|
||||
|
||||
// 콘솔에 로그
|
||||
console.error('[ErrorHandler]', errorInfo);
|
||||
|
||||
// 에러 로그에 추가
|
||||
if (this.instance) {
|
||||
this.instance._addToLog(errorInfo);
|
||||
}
|
||||
|
||||
// 콜백 실행
|
||||
if (this.instance) {
|
||||
this.instance.onErrorCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(errorInfo);
|
||||
} catch (e) {
|
||||
console.error('[ErrorHandler] Error in callback:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자에게 표시
|
||||
if (showToUser) {
|
||||
this._showToUser(errorInfo);
|
||||
}
|
||||
|
||||
return errorInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러를 정규화
|
||||
* @private
|
||||
*/
|
||||
static _normalizeError(error, context) {
|
||||
const errorInfo = {
|
||||
message: '',
|
||||
stack: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
context: {},
|
||||
...context,
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorInfo.message = error.message;
|
||||
errorInfo.stack = error.stack;
|
||||
errorInfo.name = error.name;
|
||||
} else if (typeof error === 'string') {
|
||||
errorInfo.message = error;
|
||||
} else {
|
||||
errorInfo.message = 'Unknown error';
|
||||
errorInfo.originalError = error;
|
||||
}
|
||||
|
||||
return errorInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자에게 에러 표시
|
||||
* @private
|
||||
*/
|
||||
static _showToUser(errorInfo) {
|
||||
// ModalBase가 있으면 사용, 없으면 alert
|
||||
if (typeof AlertModal !== 'undefined') {
|
||||
const alert = new AlertModal({
|
||||
title: '오류 발생',
|
||||
message: errorInfo.message || '알 수 없는 오류가 발생했습니다.',
|
||||
});
|
||||
alert.show();
|
||||
} else if (typeof ModalBase !== 'undefined') {
|
||||
// 간단한 알림 모달 생성
|
||||
const modal = new ModalBase();
|
||||
modal.create({
|
||||
content: `<div style="padding: 20px;">${errorInfo.message || '오류가 발생했습니다.'}</div>`,
|
||||
});
|
||||
modal.open();
|
||||
} else {
|
||||
// 최후의 수단: alert
|
||||
alert(errorInfo.message || '오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 로그에 추가
|
||||
* @private
|
||||
*/
|
||||
_addToLog(errorInfo) {
|
||||
this.errorLog.push(errorInfo);
|
||||
|
||||
// 최대 크기 초과 시 오래된 항목 제거
|
||||
if (this.errorLog.length > this.maxLogSize) {
|
||||
this.errorLog.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 콜백 등록
|
||||
* @param {Function} callback - 콜백 함수
|
||||
*/
|
||||
onError(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.onErrorCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 콜백 제거
|
||||
* @param {Function} callback - 콜백 함수
|
||||
*/
|
||||
offError(callback) {
|
||||
const index = this.onErrorCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.onErrorCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 로그 가져오기
|
||||
* @param {number} limit - 최대 개수
|
||||
* @returns {Array}
|
||||
*/
|
||||
getErrorLog(limit = null) {
|
||||
if (limit) {
|
||||
return this.errorLog.slice(-limit);
|
||||
}
|
||||
return [...this.errorLog];
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 로그 초기화
|
||||
*/
|
||||
clearErrorLog() {
|
||||
this.errorLog = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 안전한 함수 실행 (에러 처리 포함)
|
||||
* @param {Function} fn - 실행할 함수
|
||||
* @param {*} defaultValue - 에러 발생 시 반환할 기본값
|
||||
* @param {Object} context - 컨텍스트 정보
|
||||
* @returns {*}
|
||||
*/
|
||||
static safeExecute(fn, defaultValue = null, context = {}) {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
this.handle(error, context, false);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 안전한 비동기 함수 실행 (에러 처리 포함)
|
||||
* @param {Function} fn - 실행할 함수
|
||||
* @param {*} defaultValue - 에러 발생 시 반환할 기본값
|
||||
* @param {Object} context - 컨텍스트 정보
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static async safeExecuteAsync(fn, defaultValue = null, context = {}) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
this.handle(error, context, false);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 에러 핸들러 설정
|
||||
*/
|
||||
static setupGlobalHandlers() {
|
||||
// 전역 에러 핸들러
|
||||
window.addEventListener('error', (event) => {
|
||||
this.handle(event.error || event.message, {
|
||||
type: 'global',
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Promise rejection 핸들러
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
this.handle(event.reason, {
|
||||
type: 'unhandledRejection',
|
||||
}, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
ErrorHandler.instance = new ErrorHandler();
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = ErrorHandler;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 이벤트 관리 모듈 (EventManager.js)
|
||||
* ========================================
|
||||
* 이벤트 리스너를 중앙에서 관리합니다.
|
||||
* 리스너 ID를 저장해두면 나중에 off()로 제거 가능 (메모리 누수 방지).
|
||||
*
|
||||
* [초보자용] 전역 변수 eventManager 로 사용:
|
||||
* eventManager.on(element, 'click', handler)
|
||||
* eventManager.delegate(parent, 'click', '.btn', handler)
|
||||
*
|
||||
* @module EventManager
|
||||
*/
|
||||
class EventManager {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
this.delegatedListeners = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 등록
|
||||
* @param {Element|Window|Document} target - 대상 요소
|
||||
* @param {string} eventType - 이벤트 타입
|
||||
* @param {Function} handler - 핸들러 함수
|
||||
* @param {Object} options - 이벤트 옵션
|
||||
* @returns {string} 리스너 ID
|
||||
*/
|
||||
on(target, eventType, handler, options = {}) {
|
||||
if (!target || typeof handler !== 'function') {
|
||||
console.warn('[EventManager] Invalid target or handler');
|
||||
return null;
|
||||
}
|
||||
|
||||
const listenerId = this._generateId();
|
||||
const wrappedHandler = this._wrapHandler(handler, listenerId);
|
||||
|
||||
target.addEventListener(eventType, wrappedHandler, options);
|
||||
|
||||
if (!this.listeners.has(target)) {
|
||||
this.listeners.set(target, new Map());
|
||||
}
|
||||
this.listeners.get(target).set(listenerId, {
|
||||
eventType,
|
||||
handler: wrappedHandler,
|
||||
originalHandler: handler,
|
||||
options,
|
||||
});
|
||||
|
||||
return listenerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 제거
|
||||
* @param {Element|Window|Document} target - 대상 요소
|
||||
* @param {string} listenerId - 리스너 ID
|
||||
*/
|
||||
off(target, listenerId) {
|
||||
if (!target || !listenerId) return;
|
||||
|
||||
const targetListeners = this.listeners.get(target);
|
||||
if (!targetListeners) return;
|
||||
|
||||
const listener = targetListeners.get(listenerId);
|
||||
if (!listener) return;
|
||||
|
||||
target.removeEventListener(listener.eventType, listener.handler, listener.options);
|
||||
targetListeners.delete(listenerId);
|
||||
|
||||
if (targetListeners.size === 0) {
|
||||
this.listeners.delete(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 위임 등록
|
||||
* @param {Element|Window|Document} parent - 부모 요소
|
||||
* @param {string} eventType - 이벤트 타입
|
||||
* @param {string} selector - 자식 선택자
|
||||
* @param {Function} handler - 핸들러 함수
|
||||
* @param {Object} options - 이벤트 옵션
|
||||
* @returns {string} 리스너 ID
|
||||
*/
|
||||
delegate(parent, eventType, selector, handler, options = {}) {
|
||||
if (!parent || typeof handler !== 'function') {
|
||||
console.warn('[EventManager] Invalid parent or handler');
|
||||
return null;
|
||||
}
|
||||
|
||||
const listenerId = this._generateId();
|
||||
const wrappedHandler = (event) => {
|
||||
const target = event.target.closest(selector);
|
||||
if (target && parent.contains(target)) {
|
||||
handler.call(target, event);
|
||||
}
|
||||
};
|
||||
|
||||
parent.addEventListener(eventType, wrappedHandler, options);
|
||||
|
||||
if (!this.delegatedListeners.has(parent)) {
|
||||
this.delegatedListeners.set(parent, new Map());
|
||||
}
|
||||
this.delegatedListeners.get(parent).set(listenerId, {
|
||||
eventType,
|
||||
selector,
|
||||
handler: wrappedHandler,
|
||||
originalHandler: handler,
|
||||
options,
|
||||
});
|
||||
|
||||
return listenerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 위임 제거
|
||||
* @param {Element|Window|Document} parent - 부모 요소
|
||||
* @param {string} listenerId - 리스너 ID
|
||||
*/
|
||||
undelegate(parent, listenerId) {
|
||||
if (!parent || !listenerId) return;
|
||||
|
||||
const delegatedListeners = this.delegatedListeners.get(parent);
|
||||
if (!delegatedListeners) return;
|
||||
|
||||
const listener = delegatedListeners.get(listenerId);
|
||||
if (!listener) return;
|
||||
|
||||
parent.removeEventListener(listener.eventType, listener.handler, listener.options);
|
||||
delegatedListeners.delete(listenerId);
|
||||
|
||||
if (delegatedListeners.size === 0) {
|
||||
this.delegatedListeners.delete(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 요소의 모든 리스너 제거
|
||||
* @param {Element|Window|Document} target - 대상 요소
|
||||
*/
|
||||
removeAll(target) {
|
||||
// 일반 리스너 제거
|
||||
const targetListeners = this.listeners.get(target);
|
||||
if (targetListeners) {
|
||||
targetListeners.forEach((listener, listenerId) => {
|
||||
target.removeEventListener(listener.eventType, listener.handler, listener.options);
|
||||
});
|
||||
this.listeners.delete(target);
|
||||
}
|
||||
|
||||
// 위임 리스너 제거
|
||||
const delegatedListeners = this.delegatedListeners.get(target);
|
||||
if (delegatedListeners) {
|
||||
delegatedListeners.forEach((listener, listenerId) => {
|
||||
target.removeEventListener(listener.eventType, listener.handler, listener.options);
|
||||
});
|
||||
this.delegatedListeners.delete(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 리스너 제거
|
||||
*/
|
||||
removeAllListeners() {
|
||||
// 일반 리스너 제거
|
||||
this.listeners.forEach((targetListeners, target) => {
|
||||
targetListeners.forEach((listener) => {
|
||||
target.removeEventListener(listener.eventType, listener.handler, listener.options);
|
||||
});
|
||||
});
|
||||
this.listeners.clear();
|
||||
|
||||
// 위임 리스너 제거
|
||||
this.delegatedListeners.forEach((delegatedListeners, parent) => {
|
||||
delegatedListeners.forEach((listener) => {
|
||||
parent.removeEventListener(listener.eventType, listener.handler, listener.options);
|
||||
});
|
||||
});
|
||||
this.delegatedListeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 번만 실행되는 이벤트 리스너
|
||||
* @param {Element|Window|Document} target - 대상 요소
|
||||
* @param {string} eventType - 이벤트 타입
|
||||
* @param {Function} handler - 핸들러 함수
|
||||
* @param {Object} options - 이벤트 옵션
|
||||
* @returns {string} 리스너 ID
|
||||
*/
|
||||
once(target, eventType, handler, options = {}) {
|
||||
const listenerId = this._generateId();
|
||||
const wrappedHandler = (event) => {
|
||||
handler(event);
|
||||
this.off(target, listenerId);
|
||||
};
|
||||
|
||||
return this.on(target, eventType, wrappedHandler, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 발생 (커스텀 이벤트)
|
||||
* @param {Element|Window|Document} target - 대상 요소
|
||||
* @param {string} eventType - 이벤트 타입
|
||||
* @param {Object} detail - 이벤트 데이터
|
||||
*/
|
||||
emit(target, eventType, detail = {}) {
|
||||
if (!target) return;
|
||||
|
||||
const event = new CustomEvent(eventType, {
|
||||
detail,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 핸들러 래핑 (에러 처리 포함)
|
||||
* @private
|
||||
*/
|
||||
_wrapHandler(handler, listenerId) {
|
||||
return (event) => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error(`[EventManager] Error in event handler (${listenerId}):`, error);
|
||||
if (typeof ErrorHandler !== 'undefined') {
|
||||
ErrorHandler.handle(error, { context: 'EventManager', listenerId });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 고유 ID 생성
|
||||
* @private
|
||||
*/
|
||||
_generateId() {
|
||||
return `listener_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 리스너 정보 가져오기
|
||||
* @returns {Object}
|
||||
*/
|
||||
getListenersInfo() {
|
||||
const info = {
|
||||
regular: {},
|
||||
delegated: {},
|
||||
};
|
||||
|
||||
this.listeners.forEach((targetListeners, target) => {
|
||||
const targetKey = target === window ? 'window' :
|
||||
target === document ? 'document' :
|
||||
target.id || target.className || 'unknown';
|
||||
info.regular[targetKey] = Array.from(targetListeners.keys());
|
||||
});
|
||||
|
||||
this.delegatedListeners.forEach((delegatedListeners, parent) => {
|
||||
const parentKey = parent === window ? 'window' :
|
||||
parent === document ? 'document' :
|
||||
parent.id || parent.className || 'unknown';
|
||||
info.delegated[parentKey] = Array.from(delegatedListeners.keys());
|
||||
});
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 EventManager 인스턴스 (싱글톤)
|
||||
*/
|
||||
const eventManager = new EventManager();
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { EventManager, eventManager };
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* 게이지 관련 기본 클래스
|
||||
* @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 };
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 학습 가이드 PDF 팝업
|
||||
* - btn-guide 클릭 시 레이어 오픈
|
||||
* - 탭 전환 시 pdf.js viewer iframe src 교체
|
||||
*/
|
||||
const LearningGuideModal = (function () {
|
||||
const DEFAULT_TAB_KEY = "player";
|
||||
const VIEWER_PATH = "/js/lib/pdfjs-viewer/web/viewer.html";
|
||||
|
||||
let layer = null;
|
||||
let viewer = null;
|
||||
let closeBtn = null;
|
||||
let triggerBtn = null;
|
||||
let tabSwiper = null;
|
||||
let tabs = [];
|
||||
let activeTabKey = DEFAULT_TAB_KEY;
|
||||
let isOpen = false;
|
||||
|
||||
function resolvePdfUrl(pdfPath) {
|
||||
if (!pdfPath) return "";
|
||||
|
||||
try {
|
||||
return new URL(pdfPath, window.location.origin).href;
|
||||
} catch (error) {
|
||||
console.error("[LearningGuideModal] PDF URL resolve failed:", error);
|
||||
return pdfPath;
|
||||
}
|
||||
}
|
||||
|
||||
function buildViewerUrl(pdfPath) {
|
||||
if (!pdfPath) return "";
|
||||
|
||||
const viewerUrl = new URL(VIEWER_PATH, window.location.origin);
|
||||
const fileUrl = resolvePdfUrl(pdfPath);
|
||||
viewerUrl.searchParams.set("file", fileUrl);
|
||||
viewerUrl.hash = "zoom=60&textlayer=off";
|
||||
return viewerUrl.href;
|
||||
}
|
||||
|
||||
function getTabButtons() {
|
||||
if (!layer) return [];
|
||||
return Array.from(layer.querySelectorAll(".learning-guide-tab"));
|
||||
}
|
||||
|
||||
function getTabButton(key) {
|
||||
return getTabButtons().find(function (tab) {
|
||||
return tab.dataset.guideKey === key;
|
||||
});
|
||||
}
|
||||
|
||||
function detectTabKeyFromPath() {
|
||||
const path = window.location.pathname.toLowerCase();
|
||||
const pathMap = [
|
||||
["index", "main"],
|
||||
["main", "main"],
|
||||
["myclass", "myclass"],
|
||||
["onboarding", "onboarding"],
|
||||
["learning", "legal"],
|
||||
["legal_edu", "legal"],
|
||||
["legal", "legal"],
|
||||
["leadership", "leadership"],
|
||||
["insight", "insight"],
|
||||
["biztrend", "biztrend"],
|
||||
["mypage", "mypage"],
|
||||
["player", "player"],
|
||||
];
|
||||
|
||||
for (let i = 0; i < pathMap.length; i += 1) {
|
||||
const needle = pathMap[i][0];
|
||||
const key = pathMap[i][1];
|
||||
if (path.indexOf(needle) !== -1 && getTabButton(key)) return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPageDefaultTabKey() {
|
||||
if (!layer) return DEFAULT_TAB_KEY;
|
||||
|
||||
const fromPath = detectTabKeyFromPath();
|
||||
if (fromPath) return fromPath;
|
||||
|
||||
const btnKey =
|
||||
triggerBtn && (triggerBtn.dataset.defaultGuideKey || "").trim();
|
||||
if (btnKey && getTabButton(btnKey)) return btnKey;
|
||||
|
||||
const key = (layer.dataset.defaultGuideKey || "").trim();
|
||||
if (key && getTabButton(key)) return key;
|
||||
|
||||
return DEFAULT_TAB_KEY;
|
||||
}
|
||||
|
||||
function getFirstAvailableTabKey() {
|
||||
const availableTab = getTabButtons().find(function (tab) {
|
||||
return tab.dataset.pdf && !tab.disabled;
|
||||
});
|
||||
return availableTab ? availableTab.dataset.guideKey : getPageDefaultTabKey();
|
||||
}
|
||||
|
||||
function initDefaultTab() {
|
||||
activeTabKey = getPageDefaultTabKey();
|
||||
updateTabStates(activeTabKey);
|
||||
}
|
||||
|
||||
function syncOpenState(tabKey) {
|
||||
const nextKey = tabKey || getPageDefaultTabKey();
|
||||
loadPdfByKey(nextKey, { forceReload: true });
|
||||
}
|
||||
|
||||
function setDefaultTab(key) {
|
||||
if (!layer || !key) return;
|
||||
|
||||
const nextKey = String(key).trim();
|
||||
if (!getTabButton(nextKey)) return;
|
||||
|
||||
layer.dataset.defaultGuideKey = nextKey;
|
||||
activeTabKey = nextKey;
|
||||
updateTabStates(nextKey);
|
||||
|
||||
if (isOpen) {
|
||||
loadPdfByKey(nextKey, { forceReload: true });
|
||||
return;
|
||||
}
|
||||
|
||||
updateTabLayout(0);
|
||||
}
|
||||
|
||||
function updateTabStates(key) {
|
||||
getTabButtons().forEach(function (tab) {
|
||||
const isActive = tab.dataset.guideKey === key;
|
||||
tab.classList.toggle("is-active", isActive);
|
||||
tab.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
});
|
||||
activeTabKey = key;
|
||||
}
|
||||
|
||||
function getTabIndex(key) {
|
||||
const tab = getTabButton(key);
|
||||
if (!tab || !tabSwiper) return -1;
|
||||
|
||||
const slide = tab.closest(".swiper-slide");
|
||||
if (!slide) return -1;
|
||||
|
||||
return Array.from(tabSwiper.slides).indexOf(slide);
|
||||
}
|
||||
|
||||
function isTabOverflow() {
|
||||
if (!tabSwiper) return false;
|
||||
|
||||
const swiperEl = tabSwiper.el;
|
||||
|
||||
// 자연 너비 기준 측정을 위해 scroll 모드로 일시 전환
|
||||
swiperEl.classList.remove("is-tab-even");
|
||||
swiperEl.classList.add("is-tab-scroll");
|
||||
tabSwiper.update();
|
||||
|
||||
return tabSwiper.wrapperEl.scrollWidth > swiperEl.clientWidth + 1;
|
||||
}
|
||||
|
||||
function updateTabLayout(speed) {
|
||||
if (!tabSwiper) return;
|
||||
|
||||
const swiperEl = tabSwiper.el;
|
||||
const overflow = isTabOverflow();
|
||||
|
||||
swiperEl.classList.toggle("is-tab-scroll", overflow);
|
||||
swiperEl.classList.toggle("is-tab-even", !overflow);
|
||||
|
||||
tabSwiper.params.centeredSlides = overflow;
|
||||
tabSwiper.params.centeredSlidesBounds = overflow;
|
||||
tabSwiper.update();
|
||||
|
||||
const index = getTabIndex(activeTabKey);
|
||||
if (index >= 0) {
|
||||
tabSwiper.slideTo(index, speed !== undefined ? speed : 300);
|
||||
}
|
||||
}
|
||||
|
||||
function isViewerBlank() {
|
||||
if (!viewer) return true;
|
||||
|
||||
const attrSrc = viewer.getAttribute("src");
|
||||
return !attrSrc || !attrSrc.trim();
|
||||
}
|
||||
|
||||
function loadPdfByKey(key, options) {
|
||||
const tab = getTabButton(key);
|
||||
if (!tab || !tab.dataset.pdf) return;
|
||||
|
||||
const forceReload = options && options.forceReload;
|
||||
updateTabStates(key);
|
||||
|
||||
if (viewer) {
|
||||
const nextSrc = buildViewerUrl(tab.dataset.pdf);
|
||||
|
||||
if (forceReload) {
|
||||
viewer.removeAttribute("src");
|
||||
requestAnimationFrame(function () {
|
||||
viewer.src = nextSrc;
|
||||
});
|
||||
} else {
|
||||
viewer.src = nextSrc;
|
||||
}
|
||||
}
|
||||
|
||||
updateTabLayout(300);
|
||||
}
|
||||
|
||||
function initTabSwiper() {
|
||||
if (typeof Swiper === "undefined" || !layer) return;
|
||||
|
||||
const swiperEl = layer.querySelector(".learning-guide-tab-swiper");
|
||||
if (!swiperEl) return;
|
||||
|
||||
tabs = getTabButtons();
|
||||
|
||||
tabSwiper = new Swiper(swiperEl, {
|
||||
slidesPerView: "auto",
|
||||
spaceBetween: 1,
|
||||
centeredSlides: false,
|
||||
centeredSlidesBounds: false,
|
||||
slideToClickedSlide: true,
|
||||
watchOverflow: true,
|
||||
speed: 300,
|
||||
resistanceRatio: 0.65,
|
||||
});
|
||||
}
|
||||
|
||||
function destroyTabSwiper() {
|
||||
if (tabSwiper) {
|
||||
tabSwiper.destroy(true, true);
|
||||
tabSwiper = null;
|
||||
}
|
||||
}
|
||||
|
||||
function open(tabKey) {
|
||||
if (!layer || isOpen) return;
|
||||
|
||||
layer.classList.remove("hidden");
|
||||
layer.classList.add("is-open");
|
||||
layer.setAttribute("aria-hidden", "false");
|
||||
isOpen = true;
|
||||
|
||||
if (typeof scrollManager !== "undefined") {
|
||||
scrollManager.lock();
|
||||
} else if (typeof bodyLock === "function") {
|
||||
bodyLock();
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
syncOpenState(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!layer || !isOpen) return;
|
||||
|
||||
layer.classList.remove("is-open");
|
||||
layer.classList.add("hidden");
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
isOpen = false;
|
||||
|
||||
if (typeof scrollManager !== "undefined") {
|
||||
scrollManager.unlock();
|
||||
} else if (typeof bodyUnlock === "function") {
|
||||
bodyUnlock();
|
||||
}
|
||||
|
||||
if (triggerBtn) {
|
||||
triggerBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabClick(event) {
|
||||
const tab = event.currentTarget;
|
||||
if (!tab || tab.disabled || tab.classList.contains("is-disabled")) return;
|
||||
|
||||
const key = tab.dataset.guideKey;
|
||||
if (!key || !tab.dataset.pdf) return;
|
||||
|
||||
if (key === activeTabKey) return;
|
||||
loadPdfByKey(key);
|
||||
}
|
||||
|
||||
function handleLayerClick(event) {
|
||||
if (event.target === layer) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (!isOpen || event.key !== "Escape") return;
|
||||
close();
|
||||
}
|
||||
|
||||
let resizeTimer = null;
|
||||
function handleResize() {
|
||||
if (!tabSwiper) return;
|
||||
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function () {
|
||||
tabSwiper.update();
|
||||
updateTabLayout(0);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
if (!triggerBtn) {
|
||||
triggerBtn = document.querySelector(".btn-guide");
|
||||
}
|
||||
if (triggerBtn) {
|
||||
triggerBtn.addEventListener("click", function () {
|
||||
open();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", function (event) {
|
||||
event.stopPropagation();
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
if (layer) {
|
||||
layer.addEventListener("click", handleLayerClick);
|
||||
const content = layer.querySelector(".learning-guide-content");
|
||||
if (content) {
|
||||
content.addEventListener("click", function (event) {
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getTabButtons().forEach(function (tab) {
|
||||
tab.addEventListener("click", handleTabClick);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
window.addEventListener("resize", handleResize);
|
||||
}
|
||||
|
||||
function init() {
|
||||
layer = document.getElementById("learningGuideLayer");
|
||||
viewer = document.getElementById("learningGuideViewer");
|
||||
closeBtn = document.getElementById("btnCloseLearningGuide");
|
||||
triggerBtn = document.querySelector(".btn-guide");
|
||||
|
||||
if (!layer || !viewer) return;
|
||||
|
||||
initTabSwiper();
|
||||
initDefaultTab();
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
open: open,
|
||||
close: close,
|
||||
switchTab: loadPdfByKey,
|
||||
setDefaultTab: setDefaultTab,
|
||||
getDefaultTab: getPageDefaultTabKey,
|
||||
buildViewerUrl: buildViewerUrl,
|
||||
destroy: destroyTabSwiper,
|
||||
};
|
||||
})();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.LearningGuideModal = LearningGuideModal;
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 모달 관련 기본 클래스
|
||||
* @module ModalBase
|
||||
*/
|
||||
class ModalBase {
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
closeOnEscape: true,
|
||||
closeOnBackdrop: true,
|
||||
showCloseButton: true,
|
||||
animation: "fade",
|
||||
animationDuration: 300,
|
||||
backdrop: true,
|
||||
keyboard: true,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.modal = null;
|
||||
this.backdrop = null;
|
||||
this.isOpen = false;
|
||||
this.onOpen = config.onOpen || null;
|
||||
this.onClose = config.onClose || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 생성
|
||||
* @param {Object} options - 옵션
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
create(options = {}) {
|
||||
const {
|
||||
id = `modal-${Date.now()}`,
|
||||
className = "",
|
||||
content = "",
|
||||
header = null,
|
||||
footer = null,
|
||||
} = options;
|
||||
|
||||
// 모달 컨테이너
|
||||
this.modal = document.createElement("div");
|
||||
this.modal.id = id;
|
||||
this.modal.className = `modal ${className}`;
|
||||
this.modal.setAttribute("role", "dialog");
|
||||
this.modal.setAttribute("aria-modal", "true");
|
||||
this.modal.style.cssText = `
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
// 백드롭
|
||||
if (this.config.backdrop) {
|
||||
this.backdrop = document.createElement("div");
|
||||
this.backdrop.className = "modal-backdrop";
|
||||
this.backdrop.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: -1;
|
||||
`;
|
||||
this.modal.appendChild(this.backdrop);
|
||||
}
|
||||
|
||||
// 모달 다이얼로그
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "modal-dialog";
|
||||
dialog.style.cssText = `
|
||||
position: relative;
|
||||
margin: 50px auto;
|
||||
max-width: 600px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
`;
|
||||
|
||||
// 모달 컨텐츠
|
||||
const modalContent = document.createElement("div");
|
||||
modalContent.className = "modal-content";
|
||||
|
||||
// 헤더
|
||||
if (header !== null) {
|
||||
const modalHeader = document.createElement("div");
|
||||
modalHeader.className = "modal-header";
|
||||
modalHeader.style.cssText = `
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
if (typeof header === "string") {
|
||||
modalHeader.innerHTML = header;
|
||||
} else {
|
||||
modalHeader.appendChild(header);
|
||||
}
|
||||
|
||||
// 닫기 버튼
|
||||
if (this.config.showCloseButton) {
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "modal-close";
|
||||
closeBtn.innerHTML = "×";
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
`;
|
||||
closeBtn.onclick = () => this.close();
|
||||
modalHeader.appendChild(closeBtn);
|
||||
}
|
||||
|
||||
modalContent.appendChild(modalHeader);
|
||||
}
|
||||
|
||||
// 바디
|
||||
const modalBody = document.createElement("div");
|
||||
modalBody.className = "modal-body";
|
||||
modalBody.style.cssText = `
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
if (typeof content === "string") {
|
||||
modalBody.innerHTML = content;
|
||||
} else {
|
||||
modalBody.appendChild(content);
|
||||
}
|
||||
|
||||
modalContent.appendChild(modalBody);
|
||||
|
||||
// 푸터
|
||||
if (footer !== null) {
|
||||
const modalFooter = document.createElement("div");
|
||||
modalFooter.className = "modal-footer";
|
||||
modalFooter.style.cssText = `
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
if (typeof footer === "string") {
|
||||
modalFooter.innerHTML = footer;
|
||||
} else {
|
||||
modalFooter.appendChild(footer);
|
||||
}
|
||||
|
||||
modalContent.appendChild(modalFooter);
|
||||
}
|
||||
|
||||
dialog.appendChild(modalContent);
|
||||
this.modal.appendChild(dialog);
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
this._setupEventListeners();
|
||||
|
||||
return this.modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
* @private
|
||||
*/
|
||||
_setupEventListeners() {
|
||||
// 백드롭 클릭
|
||||
if (this.config.closeOnBackdrop && this.backdrop) {
|
||||
this.backdrop.onclick = () => this.close();
|
||||
}
|
||||
|
||||
// ESC 키
|
||||
if (this.config.closeOnEscape) {
|
||||
this._escapeHandler = (e) => {
|
||||
if (e.key === "Escape" && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", this._escapeHandler);
|
||||
}
|
||||
|
||||
// 모달 외부 클릭
|
||||
if (this.config.closeOnBackdrop) {
|
||||
this.modal.onclick = (e) => {
|
||||
if (e.target === this.modal) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 열기
|
||||
* @param {Object} data - 전달할 데이터
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async open(data = null) {
|
||||
if (this.isOpen) return;
|
||||
|
||||
if (!this.modal) {
|
||||
console.error("Modal not created");
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM에 추가
|
||||
if (!this.modal.parentElement) {
|
||||
document.body.appendChild(this.modal);
|
||||
}
|
||||
|
||||
// onOpen 콜백
|
||||
if (this.onOpen) {
|
||||
await this.onOpen(data);
|
||||
}
|
||||
|
||||
// 애니메이션
|
||||
this.modal.style.display = "block";
|
||||
await this._animate("in");
|
||||
|
||||
this.isOpen = true;
|
||||
|
||||
// body 스크롤 방지
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 닫기
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async close() {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
// 애니메이션
|
||||
await this._animate("out");
|
||||
|
||||
this.modal.style.display = "none";
|
||||
this.isOpen = false;
|
||||
|
||||
// body 스크롤 복원
|
||||
document.body.style.overflow = "";
|
||||
|
||||
// onClose 콜백
|
||||
if (this.onClose) {
|
||||
await this.onClose();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 토글
|
||||
* @param {Object} data - 전달할 데이터
|
||||
*/
|
||||
toggle(data = null) {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 애니메이션 처리
|
||||
* @private
|
||||
*/
|
||||
async _animate(direction) {
|
||||
const dialog = this.modal.querySelector(".modal-dialog");
|
||||
const { animation, animationDuration } = this.config;
|
||||
|
||||
if (animation === "fade") {
|
||||
if (direction === "in") {
|
||||
this.modal.style.opacity = "0";
|
||||
await this._delay(10);
|
||||
this.modal.style.transition = `opacity ${animationDuration}ms`;
|
||||
this.modal.style.opacity = "1";
|
||||
await this._delay(animationDuration);
|
||||
} else {
|
||||
this.modal.style.transition = `opacity ${animationDuration}ms`;
|
||||
this.modal.style.opacity = "0";
|
||||
await this._delay(animationDuration);
|
||||
}
|
||||
} else if (animation === "slide") {
|
||||
if (direction === "in") {
|
||||
dialog.style.transform = "translateY(-50px)";
|
||||
dialog.style.opacity = "0";
|
||||
await this._delay(10);
|
||||
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
||||
dialog.style.transform = "translateY(0)";
|
||||
dialog.style.opacity = "1";
|
||||
await this._delay(animationDuration);
|
||||
} else {
|
||||
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
||||
dialog.style.transform = "translateY(-50px)";
|
||||
dialog.style.opacity = "0";
|
||||
await this._delay(animationDuration);
|
||||
}
|
||||
} else if (animation === "zoom") {
|
||||
if (direction === "in") {
|
||||
dialog.style.transform = "scale(0.7)";
|
||||
dialog.style.opacity = "0";
|
||||
await this._delay(10);
|
||||
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
||||
dialog.style.transform = "scale(1)";
|
||||
dialog.style.opacity = "1";
|
||||
await this._delay(animationDuration);
|
||||
} else {
|
||||
dialog.style.transition = `transform ${animationDuration}ms, opacity ${animationDuration}ms`;
|
||||
dialog.style.transform = "scale(0.7)";
|
||||
dialog.style.opacity = "0";
|
||||
await this._delay(animationDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// transition 초기화
|
||||
this.modal.style.transition = "";
|
||||
if (dialog) {
|
||||
dialog.style.transition = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 딜레이
|
||||
* @private
|
||||
*/
|
||||
_delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 파괴
|
||||
*/
|
||||
destroy() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
|
||||
// 이벤트 리스너 제거
|
||||
if (this._escapeHandler) {
|
||||
document.removeEventListener("keydown", this._escapeHandler);
|
||||
}
|
||||
|
||||
// DOM에서 제거
|
||||
if (this.modal && this.modal.parentElement) {
|
||||
this.modal.parentElement.removeChild(this.modal);
|
||||
}
|
||||
|
||||
this.modal = null;
|
||||
this.backdrop = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 컨텐츠 업데이트
|
||||
* @param {string|Element} content - 새 컨텐츠
|
||||
*/
|
||||
updateContent(content) {
|
||||
const modalBody = this.modal.querySelector(".modal-body");
|
||||
if (modalBody) {
|
||||
if (typeof content === "string") {
|
||||
modalBody.innerHTML = content;
|
||||
} else {
|
||||
modalBody.innerHTML = "";
|
||||
modalBody.appendChild(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 헤더 업데이트
|
||||
* @param {string|Element} header - 새 헤더
|
||||
*/
|
||||
updateHeader(header) {
|
||||
const modalHeader = this.modal.querySelector(".modal-header");
|
||||
if (modalHeader) {
|
||||
if (typeof header === "string") {
|
||||
modalHeader.innerHTML = header;
|
||||
} else {
|
||||
modalHeader.innerHTML = "";
|
||||
modalHeader.appendChild(header);
|
||||
}
|
||||
|
||||
// 닫기 버튼 재추가
|
||||
if (this.config.showCloseButton) {
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "modal-close";
|
||||
closeBtn.innerHTML = "×";
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
`;
|
||||
closeBtn.onclick = () => this.close();
|
||||
modalHeader.appendChild(closeBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확인 모달 (Confirm Dialog)
|
||||
*/
|
||||
class ConfirmModal extends ModalBase {
|
||||
constructor(config = {}) {
|
||||
super(config);
|
||||
this.promise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확인 모달 표시
|
||||
* @param {Object} options - 옵션
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
show(options = {}) {
|
||||
const {
|
||||
title = "확인",
|
||||
message = "계속하시겠습니까?",
|
||||
confirmText = "확인",
|
||||
cancelText = "취소",
|
||||
confirmClass = "btn-primary",
|
||||
cancelClass = "btn-secondary",
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 헤더
|
||||
const header = document.createElement("div");
|
||||
header.innerHTML = `<h3 style="margin: 0;">${title}</h3>`;
|
||||
|
||||
// 컨텐츠
|
||||
const content = document.createElement("div");
|
||||
content.innerHTML = message;
|
||||
|
||||
// 푸터
|
||||
const footer = document.createElement("div");
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = `btn ${cancelClass}`;
|
||||
cancelBtn.textContent = cancelText;
|
||||
cancelBtn.onclick = () => {
|
||||
this.close();
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = `btn ${confirmClass}`;
|
||||
confirmBtn.textContent = confirmText;
|
||||
confirmBtn.onclick = () => {
|
||||
this.close();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
footer.appendChild(cancelBtn);
|
||||
footer.appendChild(confirmBtn);
|
||||
|
||||
// 모달 생성 및 열기
|
||||
this.create({ header, content, footer });
|
||||
this.open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 모달 (Alert Dialog)
|
||||
*/
|
||||
class AlertModal extends ModalBase {
|
||||
/**
|
||||
* 알림 모달 표시
|
||||
* @param {Object} options - 옵션
|
||||
* @returns {Promise}
|
||||
*/
|
||||
show(options = {}) {
|
||||
const {
|
||||
title = "알림",
|
||||
message = "",
|
||||
confirmText = "확인",
|
||||
confirmClass = "btn-primary",
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 헤더
|
||||
const header = document.createElement("div");
|
||||
header.innerHTML = `<h3 style="margin: 0;">${title}</h3>`;
|
||||
|
||||
// 컨텐츠
|
||||
const content = document.createElement("div");
|
||||
content.innerHTML = message;
|
||||
|
||||
// 푸터
|
||||
const footer = document.createElement("div");
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = `btn ${confirmClass}`;
|
||||
confirmBtn.textContent = confirmText;
|
||||
confirmBtn.onclick = () => {
|
||||
this.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
footer.appendChild(confirmBtn);
|
||||
|
||||
// 모달 생성 및 열기
|
||||
this.create({ header, content, footer });
|
||||
this.open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { ModalBase, ConfirmModal, AlertModal };
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 모달 공통 유틸리티 모듈
|
||||
* 모든 모달에서 공통으로 사용되는 기능들을 모듈화
|
||||
* @module ModalUtils
|
||||
*/
|
||||
|
||||
class ModalUtils {
|
||||
/**
|
||||
* 모달 닫기 이벤트 설정 (공통)
|
||||
* @param {HTMLElement} modalElement - 모달 요소
|
||||
* @param {Object} options - 옵션
|
||||
* @param {Function} options.onClose - 닫기 시 실행할 콜백 함수
|
||||
* @param {Function} options.onCleanup - 정리 작업 콜백 함수
|
||||
* @param {string} options.closeSelector - 닫기 버튼 셀렉터 (기본: ".close")
|
||||
* @param {boolean} options.closeOnBackdrop - 배경 클릭 시 닫기 (기본: true)
|
||||
* @param {boolean} options.closeOnEscape - ESC 키로 닫기 (기본: true)
|
||||
* @returns {Object} 정리 함수들을 담은 객체
|
||||
*/
|
||||
static setupCloseEvents(modalElement, options = {}) {
|
||||
const {
|
||||
onClose = null,
|
||||
onCleanup = null,
|
||||
closeSelector = ".close",
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
} = options;
|
||||
|
||||
const cleanupFunctions = [];
|
||||
|
||||
// 닫기 함수
|
||||
const closeModal = () => {
|
||||
if (onClose && typeof onClose === "function") {
|
||||
onClose();
|
||||
} else {
|
||||
// 기본 닫기 동작
|
||||
ModalUtils.stopVideo(modalElement);
|
||||
modalElement.style.display = "none";
|
||||
setTimeout(() => {
|
||||
if (modalElement && modalElement.parentNode) {
|
||||
modalElement.parentNode.removeChild(modalElement);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 정리 작업 실행
|
||||
if (onCleanup && typeof onCleanup === "function") {
|
||||
onCleanup();
|
||||
}
|
||||
|
||||
// 등록된 정리 함수들 실행
|
||||
cleanupFunctions.forEach((fn) => {
|
||||
if (typeof fn === "function") {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
const closeBtn = modalElement.querySelector(closeSelector);
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = closeModal;
|
||||
}
|
||||
|
||||
// 배경 클릭 이벤트
|
||||
if (closeOnBackdrop) {
|
||||
modalElement.onclick = (e) => {
|
||||
if (e.target === modalElement) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ESC 키 이벤트
|
||||
let escHandler = null;
|
||||
if (closeOnEscape) {
|
||||
escHandler = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
document.removeEventListener("keydown", escHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", escHandler);
|
||||
}
|
||||
|
||||
// 정리 함수 반환
|
||||
return {
|
||||
close: closeModal,
|
||||
cleanup: () => {
|
||||
if (escHandler) {
|
||||
document.removeEventListener("keydown", escHandler);
|
||||
}
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = null;
|
||||
}
|
||||
modalElement.onclick = null;
|
||||
},
|
||||
addCleanup: (fn) => {
|
||||
if (typeof fn === "function") {
|
||||
cleanupFunctions.push(fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 정지 (공통)
|
||||
* @param {HTMLElement} modalElement - 모달 요소
|
||||
*/
|
||||
static stopVideo(modalElement) {
|
||||
// iframe 비디오 정지
|
||||
const iframe = modalElement.querySelector("#videoFrame");
|
||||
if (iframe) {
|
||||
// VideoBase가 있으면 사용, 없으면 직접 정지
|
||||
if (typeof VideoBase !== "undefined" && VideoBase.stop) {
|
||||
VideoBase.stop(iframe);
|
||||
} else {
|
||||
iframe.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
// video 태그 비디오 정지
|
||||
const video = modalElement.querySelector("video");
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer 정리 (공통)
|
||||
* @param {HTMLElement} modalElement - 모달 요소
|
||||
*/
|
||||
static cleanupObservers(modalElement) {
|
||||
// ResizeObserver 정리
|
||||
if (modalElement._resizeObserver) {
|
||||
modalElement._resizeObserver.disconnect();
|
||||
modalElement._resizeObserver = null;
|
||||
}
|
||||
|
||||
// MutationObserver 정리
|
||||
if (modalElement._mutationObserver) {
|
||||
modalElement._mutationObserver.disconnect();
|
||||
modalElement._mutationObserver = null;
|
||||
}
|
||||
|
||||
// 타이머 정리
|
||||
if (modalElement._heightAdjustTimer) {
|
||||
clearTimeout(modalElement._heightAdjustTimer);
|
||||
modalElement._heightAdjustTimer = null;
|
||||
}
|
||||
|
||||
// window resize 이벤트 리스너 제거
|
||||
if (modalElement._windowResizeHandler) {
|
||||
window.removeEventListener("resize", modalElement._windowResizeHandler);
|
||||
modalElement._windowResizeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 완전 정리 (비디오 정지 + Observer 정리)
|
||||
* @param {HTMLElement} modalElement - 모달 요소
|
||||
*/
|
||||
static cleanup(modalElement) {
|
||||
ModalUtils.stopVideo(modalElement);
|
||||
ModalUtils.cleanupObservers(modalElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 제거 (애니메이션 포함)
|
||||
* @param {HTMLElement} modalElement - 모달 요소
|
||||
* @param {Object} options - 옵션
|
||||
* @param {number} options.duration - 애니메이션 지속 시간 (기본: 300ms)
|
||||
* @param {Function} options.onComplete - 완료 콜백
|
||||
*/
|
||||
static remove(modalElement, options = {}) {
|
||||
const { duration = 300, onComplete = null } = options;
|
||||
|
||||
// 정리 작업
|
||||
ModalUtils.cleanup(modalElement);
|
||||
|
||||
// 애니메이션
|
||||
modalElement.style.opacity = "0";
|
||||
modalElement.style.transition = `opacity ${duration}ms ease`;
|
||||
|
||||
setTimeout(() => {
|
||||
if (modalElement && modalElement.parentNode) {
|
||||
modalElement.parentNode.removeChild(modalElement);
|
||||
}
|
||||
if (onComplete && typeof onComplete === "function") {
|
||||
onComplete();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 공통 유틸리티 함수 모듈 (Utils.js)
|
||||
* ========================================
|
||||
* 자주 쓰는 헬퍼 함수들을 모아둔 정적 클래스입니다.
|
||||
*
|
||||
* [초보자용 사용 예]
|
||||
* Utils.delay(1000) → 1초 대기 후 Promise 반환 (async/await와 함께)
|
||||
* Utils.debounce(fn, 300) → 입력 등 연속 호출 방지 (마지막 호출만 실행)
|
||||
* Utils.throttle(fn, 100) → resize 등 빈번한 이벤트 제한 (100ms마다 1번)
|
||||
* Utils.formatDate(date) → "YYYY-MM-DD" 형식
|
||||
* Utils.formatNumber(1234) → "1,234" (천단위 콤마)
|
||||
* Utils.storage.set('key', value) → localStorage 쉽게 사용
|
||||
*
|
||||
* @module Utils
|
||||
*/
|
||||
class Utils {
|
||||
/**
|
||||
* 딜레이 함수
|
||||
* @param {number} ms - 지연 시간 (밀리초)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
static delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바운스 함수
|
||||
* @param {Function} func - 실행할 함수
|
||||
* @param {number} wait - 대기 시간
|
||||
* @returns {Function}
|
||||
*/
|
||||
static debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰로틀 함수
|
||||
* @param {Function} func - 실행할 함수
|
||||
* @param {number} limit - 제한 시간
|
||||
* @returns {Function}
|
||||
*/
|
||||
static throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤 ID 생성
|
||||
* @param {number} length - ID 길이
|
||||
* @returns {string}
|
||||
*/
|
||||
static generateId(length = 8) {
|
||||
return Math.random()
|
||||
.toString(36)
|
||||
.substring(2, length + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 깊은 복사
|
||||
* @param {*} obj - 복사할 객체
|
||||
* @returns {*}
|
||||
*/
|
||||
static deepClone(obj) {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime());
|
||||
if (obj instanceof Array) return obj.map((item) => this.deepClone(item));
|
||||
|
||||
const clonedObj = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = this.deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 객체 병합
|
||||
* @param {Object} target - 대상 객체
|
||||
* @param {Object} source - 소스 객체
|
||||
* @returns {Object}
|
||||
*/
|
||||
static mergeDeep(target, source) {
|
||||
const output = { ...target };
|
||||
if (this.isObject(target) && this.isObject(source)) {
|
||||
Object.keys(source).forEach((key) => {
|
||||
if (this.isObject(source[key])) {
|
||||
if (!(key in target)) {
|
||||
Object.assign(output, { [key]: source[key] });
|
||||
} else {
|
||||
output[key] = this.mergeDeep(target[key], source[key]);
|
||||
}
|
||||
} else {
|
||||
Object.assign(output, { [key]: source[key] });
|
||||
}
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 객체 확인
|
||||
* @param {*} item - 확인할 항목
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isObject(item) {
|
||||
return item && typeof item === "object" && !Array.isArray(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 파라미터 파싱
|
||||
* @param {string} url - URL 문자열
|
||||
* @returns {Object}
|
||||
*/
|
||||
static parseUrlParams(url = window.location.search) {
|
||||
const params = new URLSearchParams(url);
|
||||
const result = {};
|
||||
for (const [key, value] of params) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 퍼센트 계산
|
||||
* @param {number} current - 현재 값
|
||||
* @param {number} total - 전체 값
|
||||
* @param {number} decimals - 소수점 자리수
|
||||
* @returns {number}
|
||||
*/
|
||||
static calculatePercent(current, total, decimals = 0) {
|
||||
if (total === 0) return 0;
|
||||
const percent = (current / total) * 100;
|
||||
return decimals > 0 ? parseFloat(percent.toFixed(decimals)) : Math.round(percent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열 섞기 (Fisher-Yates shuffle)
|
||||
* @param {Array} array - 섞을 배열
|
||||
* @returns {Array}
|
||||
*/
|
||||
static shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열 청크 분할
|
||||
* @param {Array} array - 분할할 배열
|
||||
* @param {number} size - 청크 크기
|
||||
* @returns {Array}
|
||||
*/
|
||||
static chunkArray(array, size) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 스토리지 관리
|
||||
*/
|
||||
static storage = {
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Storage set error:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
get(key, defaultValue = null) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (e) {
|
||||
console.error("Storage get error:", e);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
remove(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Storage remove error:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
try {
|
||||
localStorage.clear();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Storage clear error:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
* @param {Date} date - 포맷할 날짜
|
||||
* @param {string} format - 포맷 문자열 (YYYY-MM-DD, YYYY.MM.DD 등)
|
||||
* @returns {string}
|
||||
*/
|
||||
static formatDate(date, format = "YYYY-MM-DD") {
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const hour = String(d.getHours()).padStart(2, "0");
|
||||
const minute = String(d.getMinutes()).padStart(2, "0");
|
||||
const second = String(d.getSeconds()).padStart(2, "0");
|
||||
|
||||
return format
|
||||
.replace("YYYY", year)
|
||||
.replace("MM", month)
|
||||
.replace("DD", day)
|
||||
.replace("HH", hour)
|
||||
.replace("mm", minute)
|
||||
.replace("ss", second);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅 (천단위 콤마)
|
||||
* @param {number} num - 포맷할 숫자
|
||||
* @returns {string}
|
||||
*/
|
||||
static formatNumber(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 크기 포맷팅
|
||||
* @param {number} bytes - 바이트 크기
|
||||
* @param {number} decimals - 소수점 자리수
|
||||
* @returns {string}
|
||||
*/
|
||||
static formatFileSize(bytes, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Utils;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* 비디오 관련 기본 클래스
|
||||
* @module VideoBase
|
||||
*/
|
||||
class VideoBase {
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
autoplay: false,
|
||||
controls: true,
|
||||
loop: false,
|
||||
muted: false,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 비디오 URL 생성
|
||||
* @param {string} videoId - YouTube 비디오 ID
|
||||
* @param {Object} options - 추가 옵션
|
||||
* @returns {string}
|
||||
*/
|
||||
static getYouTubeUrl(videoId, options = {}) {
|
||||
const {
|
||||
autoplay = 0,
|
||||
controls = 1,
|
||||
loop = 0,
|
||||
muted = 0,
|
||||
rel = 0,
|
||||
modestbranding = 1,
|
||||
start = 0,
|
||||
} = options;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
autoplay,
|
||||
controls,
|
||||
loop,
|
||||
muted,
|
||||
rel,
|
||||
modestbranding,
|
||||
...(start > 0 && { start }),
|
||||
});
|
||||
|
||||
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 썸네일 URL 생성
|
||||
* @param {string} videoId - YouTube 비디오 ID
|
||||
* @param {string} quality - 품질 (default, hq, mq, sd, maxres)
|
||||
* @returns {string}
|
||||
*/
|
||||
static getYouTubeThumbnail(videoId, quality = "sddefault") {
|
||||
const qualities = {
|
||||
default: "default.jpg",
|
||||
hq: "hqdefault.jpg",
|
||||
mq: "mqdefault.jpg",
|
||||
sd: "sddefault.jpg",
|
||||
maxres: "maxresdefault.jpg",
|
||||
};
|
||||
|
||||
const thumbnailFile = qualities[quality] || qualities.sd;
|
||||
return `https://img.youtube.com/vi/${videoId}/${thumbnailFile}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 ID 추출 (YouTube URL에서)
|
||||
* @param {string} url - YouTube URL
|
||||
* @returns {string|null}
|
||||
*/
|
||||
static extractYouTubeId(url) {
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||
/youtube\.com\/v\/([^&\n?#]+)/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 iframe 생성
|
||||
* @param {string} videoId - 비디오 ID
|
||||
* @param {Object} options - iframe 옵션
|
||||
* @returns {HTMLIFrameElement}
|
||||
*/
|
||||
static createIframe(videoId, options = {}) {
|
||||
const {
|
||||
width = "100%",
|
||||
height = "100%",
|
||||
autoplay = 0,
|
||||
controls = 1,
|
||||
className = "",
|
||||
id = "",
|
||||
} = options;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.width = width;
|
||||
iframe.height = height;
|
||||
iframe.src = this.getYouTubeUrl(videoId, { autoplay, controls });
|
||||
iframe.frameBorder = "0";
|
||||
iframe.allow =
|
||||
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
|
||||
iframe.allowFullscreen = true;
|
||||
|
||||
if (className) iframe.className = className;
|
||||
if (id) iframe.id = id;
|
||||
|
||||
return iframe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 재생
|
||||
* @param {HTMLIFrameElement} iframe - iframe 요소
|
||||
*/
|
||||
static play(iframe) {
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
iframe.contentWindow.postMessage(
|
||||
'{"event":"command","func":"playVideo","args":""}',
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 일시정지
|
||||
* @param {HTMLIFrameElement} iframe - iframe 요소
|
||||
*/
|
||||
static pause(iframe) {
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
iframe.contentWindow.postMessage(
|
||||
'{"event":"command","func":"pauseVideo","args":""}',
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 정지
|
||||
* @param {HTMLIFrameElement} iframe - iframe 요소
|
||||
*/
|
||||
static stop(iframe) {
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
iframe.contentWindow.postMessage(
|
||||
'{"event":"command","func":"stopVideo","args":""}',
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 시간 이동
|
||||
* @param {HTMLIFrameElement} iframe - iframe 요소
|
||||
* @param {number} seconds - 이동할 시간 (초)
|
||||
*/
|
||||
static seekTo(iframe, seconds) {
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
iframe.contentWindow.postMessage(
|
||||
`{"event":"command","func":"seekTo","args":[${seconds}, true]}`,
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 볼륨 설정
|
||||
* @param {HTMLIFrameElement} iframe - iframe 요소
|
||||
* @param {number} volume - 볼륨 (0-100)
|
||||
*/
|
||||
static setVolume(iframe, volume) {
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
const vol = Math.max(0, Math.min(100, volume));
|
||||
iframe.contentWindow.postMessage(
|
||||
`{"event":"command","func":"setVolume","args":[${vol}]}`,
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 음소거 토글
|
||||
* @param {HTMLIFrameElement} iframe - iframe 요소
|
||||
* @param {boolean} mute - 음소거 여부
|
||||
*/
|
||||
static toggleMute(iframe, mute) {
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
const func = mute ? "mute" : "unMute";
|
||||
iframe.contentWindow.postMessage(
|
||||
`{"event":"command","func":"${func}","args":""}`,
|
||||
"*"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 데이터 모델
|
||||
*/
|
||||
class VideoModel {
|
||||
constructor(data = {}) {
|
||||
this.id = data.id || null;
|
||||
this.url = data.url || "";
|
||||
this.title = data.title || "";
|
||||
this.category = data.category || "";
|
||||
this.subcate = data.subcate || "";
|
||||
this.keywords = data.keywords || [];
|
||||
this.bookmark = data.bookmark || false;
|
||||
this.completed = data.completed || false;
|
||||
this.picker = data.picker || "";
|
||||
this.type = data.type || "main";
|
||||
this.gauge = data.gauge || 0;
|
||||
this.description = data.description || "";
|
||||
this.duration = data.duration || 0;
|
||||
this.createdAt = data.createdAt || new Date();
|
||||
this.updatedAt = data.updatedAt || new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 ID 가져오기
|
||||
* @returns {string}
|
||||
*/
|
||||
getVideoId() {
|
||||
return VideoBase.extractYouTubeId(this.url) || this.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일 URL 가져오기
|
||||
* @param {string} quality - 품질
|
||||
* @returns {string}
|
||||
*/
|
||||
getThumbnailUrl(quality = "sd") {
|
||||
const videoId = this.getVideoId();
|
||||
return VideoBase.getYouTubeThumbnail(videoId, quality);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임베드 URL 가져오기
|
||||
* @param {Object} options - 옵션
|
||||
* @returns {string}
|
||||
*/
|
||||
getEmbedUrl(options = {}) {
|
||||
const videoId = this.getVideoId();
|
||||
return VideoBase.getYouTubeUrl(videoId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크 토글
|
||||
*/
|
||||
toggleBookmark() {
|
||||
this.bookmark = !this.bookmark;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료 상태 설정
|
||||
* @param {boolean} completed - 완료 여부
|
||||
*/
|
||||
setCompleted(completed) {
|
||||
this.completed = completed;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON으로 변환
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
url: this.url,
|
||||
title: this.title,
|
||||
category: this.category,
|
||||
subcate: this.subcate,
|
||||
keywords: this.keywords,
|
||||
bookmark: this.bookmark,
|
||||
completed: this.completed,
|
||||
picker: this.picker,
|
||||
type: this.type,
|
||||
gauge: this.gauge,
|
||||
description: this.description,
|
||||
duration: this.duration,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 컬렉션 관리
|
||||
*/
|
||||
class VideoCollection {
|
||||
constructor(videos = []) {
|
||||
this.videos = videos.map((v) => (v instanceof VideoModel ? v : new VideoModel(v)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 추가
|
||||
* @param {Object|VideoModel} video - 비디오 데이터
|
||||
*/
|
||||
add(video) {
|
||||
const model = video instanceof VideoModel ? video : new VideoModel(video);
|
||||
this.videos.push(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 제거
|
||||
* @param {number|string} id - 비디오 ID
|
||||
*/
|
||||
remove(id) {
|
||||
this.videos = this.videos.filter((v) => v.id !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 비디오 찾기
|
||||
* @param {number|string} id - 비디오 ID
|
||||
* @returns {VideoModel|null}
|
||||
*/
|
||||
findById(id) {
|
||||
return this.videos.find((v) => v.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터링
|
||||
* @param {Function} predicate - 필터 함수
|
||||
* @returns {VideoCollection}
|
||||
*/
|
||||
filter(predicate) {
|
||||
return new VideoCollection(this.videos.filter(predicate));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리로 필터링
|
||||
* @param {string} category - 카테고리
|
||||
* @returns {VideoCollection}
|
||||
*/
|
||||
filterByCategory(category) {
|
||||
return this.filter((v) => v.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 키워드로 필터링
|
||||
* @param {Array} keywords - 키워드 배열
|
||||
* @returns {VideoCollection}
|
||||
*/
|
||||
filterByKeywords(keywords) {
|
||||
return this.filter((v) => keywords.some((k) => v.keywords.includes(k)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크된 비디오만
|
||||
* @returns {VideoCollection}
|
||||
*/
|
||||
getBookmarked() {
|
||||
return this.filter((v) => v.bookmark);
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료된 비디오만
|
||||
* @returns {VideoCollection}
|
||||
*/
|
||||
getCompleted() {
|
||||
return this.filter((v) => v.completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미완료 비디오만
|
||||
* @returns {VideoCollection}
|
||||
*/
|
||||
getIncomplete() {
|
||||
return this.filter((v) => !v.completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬
|
||||
* @param {Function} compareFn - 비교 함수
|
||||
* @returns {VideoCollection}
|
||||
*/
|
||||
sort(compareFn) {
|
||||
return new VideoCollection([...this.videos].sort(compareFn));
|
||||
}
|
||||
|
||||
/**
|
||||
* 개수
|
||||
* @returns {number}
|
||||
*/
|
||||
count() {
|
||||
return this.videos.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열로 변환
|
||||
* @returns {Array}
|
||||
*/
|
||||
toArray() {
|
||||
return this.videos;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON으로 변환
|
||||
* @returns {Array}
|
||||
*/
|
||||
toJSON() {
|
||||
return this.videos.map((v) => v.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
// ES6 모듈 내보내기
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { VideoBase, VideoModel, VideoCollection };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user