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;
}