506 lines
14 KiB
JavaScript
506 lines
14 KiB
JavaScript
/**
|
|
* 애니메이션 유틸리티 모듈 (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;
|
|
}
|