Initial commit: 교육 프로젝트 배포

This commit is contained in:
송대일
2026-07-01 18:32:42 +09:00
commit be6dccd120
1483 changed files with 5082202 additions and 0 deletions
+505
View File
@@ -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;
}
+414
View File
@@ -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 };
}
+414
View File
@@ -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;
}
+116
View File
@@ -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 };
}
+221
View File
@@ -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;
}
+276
View File
@@ -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 };
}
+434
View File
@@ -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 };
}
+375
View File
@@ -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;
}
+520
View File
@@ -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 = "&times;";
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 = "&times;";
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 };
}
+194
View File
@@ -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);
}
}
+277
View File
@@ -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;
}
+409
View File
@@ -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