Files
edu/js/main/MultiGuide.js
T

1212 lines
46 KiB
JavaScript

// multiGuide.js
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils)
class MultiGuide {
constructor(targets, dependencies = {}) {
// 의존성 주입 (폴백 포함)
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null);
this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null);
this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null);
// 이벤트 리스너 ID 저장 (정리용)
this.listenerIds = [];
this.resizeTimer = null; // resize 타이머 저장
try {
// 입력값 유효성 검증
if (!targets || !Array.isArray(targets)) {
this._handleError(new Error('targets가 배열이 아닙니다.'), 'constructor');
this.targets = [];
} else {
this.targets = targets;
}
this.isActive = false;
this.init();
} catch (error) {
this._handleError(error, 'constructor');
}
}
/**
* 에러 처리 헬퍼
* @private
*/
_handleError(error, context, additionalInfo = {}) {
if (this.errorHandler) {
this.errorHandler.handle(error, {
context: `MultiGuide.${context}`,
component: 'MultiGuide',
...additionalInfo
}, false);
} else {
console.error(`[MultiGuide] ${context}:`, error, additionalInfo);
}
}
init() {
try {
console.log("[MultiGuide.init] 초기화 시작");
this.guideWrap = this.domUtils?.$(".guide-wrap") || document.querySelector(".guide-wrap");
console.log("[MultiGuide.init] guideWrap:", this.guideWrap);
if (!this.guideWrap) {
console.error("[MultiGuide.init] ❌ .guide-wrap 요소를 찾을 수 없습니다!");
setTimeout(() => {
try {
this.guideWrap = this.domUtils?.$(".guide-wrap") || document.querySelector(".guide-wrap");
if (this.guideWrap) {
console.log("[MultiGuide.init] ✅ 재시도 후 guideWrap 찾음");
this.continueInit();
} else {
console.error("[MultiGuide.init] ❌ 재시도 실패");
this._handleError(new Error('.guide-wrap 요소를 찾을 수 없습니다.'), 'init.retry');
}
} catch (error) {
this._handleError(error, 'init.retry');
}
}, 100);
return;
}
this.continueInit();
} catch (error) {
this._handleError(error, 'init');
}
}
continueInit() {
try {
if (!this.guideWrap) {
this._handleError(new Error('guideWrap이 없습니다.'), 'continueInit');
return;
}
this.cutoutPath = this.domUtils?.$("#guide-cutout-path") || document.getElementById("guide-cutout-path");
this.strokePath = this.domUtils?.$("#guide-stroke-path") || document.getElementById("guide-stroke-path");
this.arcEllipseStrokePath = this.domUtils?.$("#guide-arc-ellipse-stroke-path") || document.getElementById("guide-arc-ellipse-stroke-path");
this.arcStrokeMaskPath = this.domUtils?.$("#guide-arc-stroke-mask-path") || document.getElementById("guide-arc-stroke-mask-path");
this.connectionLinesGroup = this.domUtils?.$("#guide-connection-lines") || document.getElementById("guide-connection-lines");
this.elementConnectionLinesGroup = this.domUtils?.$("#guide-element-connection-lines") || document.getElementById("guide-element-connection-lines");
this.bordersContainer = this.domUtils?.$("#guideBorders") || document.getElementById("guideBorders");
this.labelsContainer = this.domUtils?.$("#guideLabels") || document.getElementById("guideLabels");
if (!this.bordersContainer) {
this.bordersContainer = this.domUtils?.createElement('div', {
id: 'guideBorders',
class: 'guide-borders'
}) || document.createElement("div");
if (!this.domUtils) {
this.bordersContainer.id = "guideBorders";
this.bordersContainer.className = "guide-borders";
}
this.guideWrap.appendChild(this.bordersContainer);
}
if (!this.labelsContainer) {
this.labelsContainer = this.domUtils?.createElement('div', {
id: 'guideLabels',
class: 'guide-labels',
style: {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: '10000'
}
}) || document.createElement("div");
if (!this.domUtils) {
this.labelsContainer.id = "guideLabels";
this.labelsContainer.className = "guide-labels";
this.labelsContainer.style.cssText =
"position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10000;";
}
this.guideWrap.appendChild(this.labelsContainer);
}
this.attachEvents();
console.log("[MultiGuide.init] ✅ 초기화 완료");
} catch (error) {
this._handleError(error, 'continueInit');
}
}
getStartButton() {
try {
return (
this.domUtils?.$(".guide-start-btn") ||
this.domUtils?.$("#guideStart") ||
this.domUtils?.$("[data-guide-start]") ||
document.querySelector(".guide-start-btn") ||
document.getElementById("guideStart") ||
document.querySelector("[data-guide-start]")
);
} catch (error) {
this._handleError(error, 'getStartButton');
return null;
}
}
attachEvents() {
try {
const startBtn = this.getStartButton();
if (startBtn) {
const startBtnHandler = (e) => {
try {
e.preventDefault();
e.stopPropagation();
this.show();
} catch (error) {
this._handleError(error, 'attachEvents.startBtnHandler');
}
};
if (this.eventManager) {
const listenerId = this.eventManager.on(startBtn, "click", startBtnHandler);
this.listenerIds.push({ element: startBtn, id: listenerId, type: 'click' });
} else {
startBtn.addEventListener("click", startBtnHandler);
}
}
const svg = this.domUtils?.$(".guide-svg") || document.querySelector(".guide-svg");
if (svg) {
const svgHandler = (e) => {
try {
if (e.target === svg || e.target.tagName.toLowerCase() === "rect") {
this.hide();
}
} catch (error) {
this._handleError(error, 'attachEvents.svgHandler');
}
};
if (this.eventManager) {
const listenerId = this.eventManager.on(svg, "click", svgHandler);
this.listenerIds.push({ element: svg, id: listenerId, type: 'click' });
} else {
svg.addEventListener("click", svgHandler);
}
}
const keydownHandler = (e) => {
try {
if (e.key === "Escape" && this.isActive) {
this.hide();
}
} catch (error) {
this._handleError(error, 'attachEvents.keydownHandler');
}
};
if (this.eventManager) {
const listenerId = this.eventManager.on(document, "keydown", keydownHandler);
this.listenerIds.push({ element: document, id: listenerId, type: 'keydown' });
} else {
document.addEventListener("keydown", keydownHandler);
}
// 화면 리사이즈 시 재측정
const resizeHandler = () => {
try {
if (!this.isActive) return;
// 디바운싱: 100ms 후에 재측정
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
}
// Utils.debounce 사용 (있는 경우)
if (this.utils && this.utils.debounce) {
const debouncedResize = this.utils.debounce(() => {
console.log("[MultiGuide] 화면 리사이즈 감지 - 재측정 중...");
this.createCutouts();
this.createBorders();
this.createLabels();
}, 100);
this.resizeTimer = setTimeout(debouncedResize, 100);
} else {
this.resizeTimer = setTimeout(() => {
console.log("[MultiGuide] 화면 리사이즈 감지 - 재측정 중...");
this.createCutouts();
this.createBorders();
this.createLabels();
}, 100);
}
} catch (error) {
this._handleError(error, 'attachEvents.resizeHandler');
}
};
if (this.eventManager) {
const listenerId = this.eventManager.on(window, "resize", resizeHandler);
this.listenerIds.push({ element: window, id: listenerId, type: 'resize' });
} else {
window.addEventListener("resize", resizeHandler);
}
} catch (error) {
this._handleError(error, 'attachEvents');
}
}
show() {
try {
console.log("[MultiGuide.show] 가이드 표시 시작");
if (!this.guideWrap) {
this._handleError(new Error('guideWrap을 찾을 수 없습니다.'), 'show');
return;
}
this.isActive = true;
if (this.domUtils) {
this.domUtils.addClasses(this.guideWrap, 'active');
} else {
this.guideWrap.classList.add("active");
}
const startBtn = this.getStartButton();
if (startBtn) {
if (this.domUtils) {
this.domUtils.setStyles(startBtn, { display: 'none' });
} else {
startBtn.style.display = "none";
}
}
if (this.domUtils) {
this.domUtils.setStyles(document.body, { overflow: 'hidden' });
} else {
document.body.style.overflow = "hidden";
}
this.createCutouts();
this.createBorders();
this.createLabels();
// 🔥 createLabels 이후에 키워드 연결선 생성
setTimeout(() => {
try {
this.createKeywordElementConnection();
} catch (error) {
this._handleError(error, 'show.createKeywordElementConnection');
}
}, 100);
console.log("[MultiGuide.show] ✅ 가이드 표시 완료");
} catch (error) {
this._handleError(error, 'show');
}
}
hide() {
try {
this.isActive = false;
if (this.guideWrap) {
if (this.domUtils) {
this.domUtils.removeClasses(this.guideWrap, 'active');
} else {
this.guideWrap.classList.remove("active");
}
}
const startBtn = this.getStartButton();
if (startBtn) {
if (this.domUtils) {
this.domUtils.setStyles(startBtn, { display: 'block' });
} else {
startBtn.style.display = "block";
}
}
if (this.domUtils) {
this.domUtils.setStyles(document.body, { overflow: '' });
} else {
document.body.style.overflow = "";
}
if (this.bordersContainer) this.bordersContainer.innerHTML = "";
if (this.labelsContainer) this.labelsContainer.innerHTML = "";
if (this.connectionLinesGroup) this.connectionLinesGroup.innerHTML = "";
if (this.elementConnectionLinesGroup) this.elementConnectionLinesGroup.innerHTML = "";
} catch (error) {
this._handleError(error, 'hide');
}
}
createCutouts() {
try {
if (!this.cutoutPath) {
this._handleError(new Error('cutoutPath가 없습니다.'), 'createCutouts');
return;
}
let pathData = "";
let strokePathData = "";
let arcEllipseStrokePathData = "";
let arcStrokeMaskPathData = "";
if (!this.targets || !Array.isArray(this.targets)) {
this._handleError(new Error('targets가 배열이 아닙니다.'), 'createCutouts');
return;
}
this.targets.forEach((target, index) => {
try {
if (!target || !target.selector) {
console.warn(`[MultiGuide] target ${index}가 유효하지 않습니다.`);
return;
}
const element = this.domUtils?.$(target.selector) || document.querySelector(target.selector);
if (!element) {
console.warn("[MultiGuide] 요소를 찾을 수 없음:", target.selector);
return;
}
console.log("[createCutouts]", {
selector: target.selector,
useGaugeArc: target.useGaugeArc,
shape: target.shape
});
// 🔥 gauge arc 처리
if (target.useGaugeArc) {
console.log("[createCutouts] 게이지 아크 생성 중...");
const maskPath = this.createGaugeArcStrokeMaskPath(element, target);
console.log("[createCutouts] maskPath 길이:", maskPath.length);
arcStrokeMaskPathData += maskPath + " ";
const strokePath = this.createGaugeArcEllipseStrokeBoundaryPath(element, target);
console.log("[createCutouts] strokePath 길이:", strokePath.length);
arcEllipseStrokePathData += strokePath + " ";
}
// 🔥 기본 사각형 처리
else {
const rect = element.getBoundingClientRect();
const padding = target.padding || 0;
const radius = target.borderRadius || 8;
pathData += this.createRoundedRectPath(
rect.left - padding,
rect.top - padding,
rect.width + padding * 2,
rect.height + padding * 2,
radius
) + " ";
strokePathData += this.createRoundedRectStrokePath(
rect.left - padding,
rect.top - padding,
rect.width + padding * 2,
rect.height + padding * 2,
radius
) + " ";
}
} catch (error) {
this._handleError(error, 'createCutouts.target', { index, target });
}
});
this.cutoutPath.setAttribute("d", pathData.trim());
if (this.strokePath) {
this.strokePath.setAttribute("d", strokePathData.trim());
}
if (this.arcStrokeMaskPath) {
console.log("[createCutouts] arcStrokeMaskPath 설정:", arcStrokeMaskPathData.trim() ? "있음" : "없음");
this.arcStrokeMaskPath.setAttribute("d", arcStrokeMaskPathData.trim());
}
if (this.arcEllipseStrokePath) {
console.log("[createCutouts] arcEllipseStrokePath 설정:", arcEllipseStrokePathData.trim() ? "있음" : "없음");
this.arcEllipseStrokePath.setAttribute("d", arcEllipseStrokePathData.trim());
}
} catch (error) {
this._handleError(error, 'createCutouts');
}
}
createGaugeArcStrokeMaskPath(containerElement, target) {
try {
const gaugeSvg = this.domUtils?.$("#gauge") || document.getElementById("gauge");
if (!gaugeSvg) {
console.warn("[MultiGuide] gauge SVG를 찾을 수 없습니다");
return "";
}
const size = target.gaugeSize || 832;
const strokeWidth = target.gaugeStrokeWidth || 31;
const padding = target.gaugePadding || 20;
const center = size / 2;
const radius = size / 2 - strokeWidth / 2 - padding;
const svgRect = gaugeSvg.getBoundingClientRect();
const viewBox = gaugeSvg.viewBox.baseVal;
const scaleX = svgRect.width / viewBox.width;
const scaleY = svgRect.height / viewBox.height;
const screenCenterX = svgRect.left + (center - viewBox.x) * scaleX;
const screenCenterY = svgRect.top + (center - viewBox.y) * scaleY;
const screenRadius = radius * scaleX;
const screenStrokeWidth = strokeWidth * scaleX;
const startAngle = target.startAngle || 180;
const endAngle = target.endAngle || 360;
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const outerRadius = screenRadius + screenStrokeWidth / 2;
const innerRadius = screenRadius - screenStrokeWidth / 2;
const outerStartX = screenCenterX + outerRadius * Math.cos(startRad);
const outerStartY = screenCenterY + outerRadius * Math.sin(startRad);
const outerEndX = screenCenterX + outerRadius * Math.cos(endRad);
const outerEndY = screenCenterY + outerRadius * Math.sin(endRad);
const innerStartX = screenCenterX + innerRadius * Math.cos(startRad);
const innerStartY = screenCenterY + innerRadius * Math.sin(startRad);
const innerEndX = screenCenterX + innerRadius * Math.cos(endRad);
const innerEndY = screenCenterY + innerRadius * Math.sin(endRad);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
let pathData = `
M ${outerStartX},${outerStartY}
A ${outerRadius},${outerRadius} 0 ${largeArc} 1 ${outerEndX},${outerEndY}
L ${innerEndX},${innerEndY}
A ${innerRadius},${innerRadius} 0 ${largeArc} 0 ${innerStartX},${innerStartY}
Z
`;
const startCapCenterX = screenCenterX + screenRadius * Math.cos(startRad);
const startCapCenterY = screenCenterY + screenRadius * Math.sin(startRad);
const capRadius = screenStrokeWidth / 2;
pathData += `
M ${startCapCenterX + capRadius},${startCapCenterY}
A ${capRadius},${capRadius} 0 1 1 ${startCapCenterX - capRadius},${startCapCenterY}
A ${capRadius},${capRadius} 0 1 1 ${startCapCenterX + capRadius},${startCapCenterY}
Z
`;
const endCapCenterX = screenCenterX + screenRadius * Math.cos(endRad);
const endCapCenterY = screenCenterY + screenRadius * Math.sin(endRad);
pathData += `
M ${endCapCenterX + capRadius},${endCapCenterY}
A ${capRadius},${capRadius} 0 1 1 ${endCapCenterX - capRadius},${endCapCenterY}
A ${capRadius},${capRadius} 0 1 1 ${endCapCenterX + capRadius},${endCapCenterY}
Z
`;
return pathData;
} catch (error) {
this._handleError(error, 'createGaugeArcStrokeMaskPath', { target });
return "";
}
}
createGaugeArcEllipseStrokeBoundaryPath(containerElement, target) {
try {
const gaugeSvg = this.domUtils?.$("#gauge") || document.getElementById("gauge");
if (!gaugeSvg) {
console.warn("[MultiGuide] gauge SVG를 찾을 수 없습니다");
return "";
}
const size = target.gaugeSize || 832;
const strokeWidth = target.gaugeStrokeWidth || 31;
const padding = target.gaugePadding || 20;
const center = size / 2;
const radius = size / 2 - strokeWidth / 2 - padding;
const svgRect = gaugeSvg.getBoundingClientRect();
const viewBox = gaugeSvg.viewBox.baseVal;
const scaleX = svgRect.width / viewBox.width;
const scaleY = svgRect.height / viewBox.height;
const screenCenterX = svgRect.left + (center - viewBox.x) * scaleX;
const screenCenterY = svgRect.top + (center - viewBox.y) * scaleY;
const screenRadius = radius * scaleX;
const screenStrokeWidth = strokeWidth * scaleX;
const startAngle = target.startAngle || 180;
const endAngle = target.endAngle || 360;
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const outerRadius = screenRadius + screenStrokeWidth / 2;
const innerRadius = screenRadius - screenStrokeWidth / 2;
const outerStartX = screenCenterX + outerRadius * Math.cos(startRad);
const outerStartY = screenCenterY + outerRadius * Math.sin(startRad);
const outerEndX = screenCenterX + outerRadius * Math.cos(endRad);
const outerEndY = screenCenterY + outerRadius * Math.sin(endRad);
const innerStartX = screenCenterX + innerRadius * Math.cos(startRad);
const innerStartY = screenCenterY + innerRadius * Math.sin(startRad);
const innerEndX = screenCenterX + innerRadius * Math.cos(endRad);
const innerEndY = screenCenterY + innerRadius * Math.sin(endRad);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
const capRadius = screenStrokeWidth / 2;
const startCapCenterX = screenCenterX + screenRadius * Math.cos(startRad);
const startCapCenterY = screenCenterY + screenRadius * Math.sin(startRad);
const endCapCenterX = screenCenterX + screenRadius * Math.cos(endRad);
const endCapCenterY = screenCenterY + screenRadius * Math.sin(endRad);
let pathData = "";
pathData += `M ${outerStartX},${outerStartY} A ${outerRadius},${outerRadius} 0 ${largeArc} 1 ${outerEndX},${outerEndY} `;
pathData += `M ${innerStartX},${innerStartY} A ${innerRadius},${innerRadius} 0 ${largeArc} 1 ${innerEndX},${innerEndY} `;
const startCapLeftX = startCapCenterX - capRadius;
const startCapRightX = startCapCenterX + capRadius;
pathData += `M ${startCapLeftX},${startCapCenterY} A ${capRadius},${capRadius} 0 0 0 ${startCapRightX},${startCapCenterY} `;
const endCapLeftX = endCapCenterX - capRadius;
const endCapRightX = endCapCenterX + capRadius;
pathData += `M ${endCapLeftX},${endCapCenterY} A ${capRadius},${capRadius} 0 0 0 ${endCapRightX},${endCapCenterY}`;
return pathData.trim();
} catch (error) {
this._handleError(error, 'createGaugeArcEllipseStrokeBoundaryPath', { target });
return "";
}
}
createRoundedRectPath(x, y, width, height, radius) {
return `
M ${x + radius},${y}
L ${x + width - radius},${y}
Q ${x + width},${y} ${x + width},${y + radius}
L ${x + width},${y + height - radius}
Q ${x + width},${y + height} ${x + width - radius},${y + height}
L ${x + radius},${y + height}
Q ${x},${y + height} ${x},${y + height - radius}
L ${x},${y + radius}
Q ${x},${y} ${x + radius},${y}
Z
`;
}
createRoundedRectStrokePath(x, y, width, height, radius) {
return `
M ${x + radius},${y}
L ${x + width - radius},${y}
Q ${x + width},${y} ${x + width},${y + radius}
L ${x + width},${y + height - radius}
Q ${x + width},${y + height} ${x + width - radius},${y + height}
L ${x + radius},${y + height}
Q ${x},${y + height} ${x},${y + height - radius}
L ${x},${y + radius}
Q ${x},${y} ${x + radius},${y}
Z
`;
}
createBorders() {
try {
if (!this.bordersContainer) {
console.warn("[MultiGuide] bordersContainer가 없습니다.");
return;
}
if (!this.targets || !Array.isArray(this.targets)) {
this._handleError(new Error('targets가 배열이 아닙니다.'), 'createBorders');
return;
}
this.targets.forEach((target, index) => {
try {
if (!target || !target.selector) {
console.warn(`[MultiGuide] target ${index}가 유효하지 않습니다.`);
return;
}
const element = this.domUtils?.$(target.selector) || document.querySelector(target.selector);
if (!element) {
console.warn(`[MultiGuide] 요소를 찾을 수 없음: ${target.selector}`);
return;
}
if (target.useGaugeArc || target.shape === "arc") {
return;
}
const rect = element.getBoundingClientRect();
const padding = target.padding || 15;
const border = this.domUtils?.createElement('div', {
class: 'guide-border',
style: {
left: `${rect.left - padding}px`,
top: `${rect.top - padding}px`,
width: `${rect.width + padding * 2}px`,
height: `${rect.height + padding * 2}px`,
animationDelay: `${index * 0.1}s`
}
}) || document.createElement("div");
if (!this.domUtils) {
border.className = "guide-border";
border.style.cssText = `
left: ${rect.left - padding}px;
top: ${rect.top - padding}px;
width: ${rect.width + padding * 2}px;
height: ${rect.height + padding * 2}px;
animation-delay: ${index * 0.1}s;
`;
}
this.bordersContainer.appendChild(border);
} catch (error) {
this._handleError(error, 'createBorders.target', { index, target });
}
});
} catch (error) {
this._handleError(error, 'createBorders');
}
}
createConnectionLine(elementRect, maskRect, tooltipBox, target, horizontalPos, verticalPos) {
if (!this.connectionLinesGroup) return;
// tooltip의 실제 위치 계산
const tooltipRect = tooltipBox.getBoundingClientRect();
const tooltipCenterX = tooltipRect.left + tooltipRect.width / 2;
const tooltipCenterY = tooltipRect.top + tooltipRect.height / 2;
// 요소의 연결점 계산 (요소의 가장 가까운 모서리 또는 중앙)
let elementX, elementY;
if (horizontalPos === "left") {
// tooltip이 왼쪽에 있으면 요소의 왼쪽 중앙
elementX = elementRect.left;
elementY = elementRect.top + elementRect.height / 2;
} else if (horizontalPos === "right") {
// tooltip이 오른쪽에 있으면 요소의 오른쪽 중앙
elementX = elementRect.right;
elementY = elementRect.top + elementRect.height / 2;
} else {
// center일 때
if (verticalPos === "top") {
// tooltip이 위에 있으면 요소의 위쪽 중앙
elementX = elementRect.left + elementRect.width / 2;
elementY = elementRect.top;
} else if (verticalPos === "bottom") {
// tooltip이 아래에 있으면 요소의 아래쪽 중앙
elementX = elementRect.left + elementRect.width / 2;
elementY = elementRect.bottom;
} else {
// center center일 때는 요소의 중앙
elementX = elementRect.left + elementRect.width / 2;
elementY = elementRect.top + elementRect.height / 2;
}
}
// tooltip의 연결점 계산
let tooltipX, tooltipY;
if (horizontalPos === "left") {
// tooltip이 왼쪽에 있으면 tooltip의 오른쪽 중앙
tooltipX = tooltipRect.right;
tooltipY = tooltipRect.top + tooltipRect.height / 2;
} else if (horizontalPos === "right") {
// tooltip이 오른쪽에 있으면 tooltip의 왼쪽 중앙
tooltipX = tooltipRect.left;
tooltipY = tooltipRect.top + tooltipRect.height / 2;
} else {
// center일 때
if (verticalPos === "top") {
// tooltip이 위에 있으면 tooltip의 아래쪽 중앙
tooltipX = tooltipRect.left + tooltipRect.width / 2;
tooltipY = tooltipRect.bottom;
} else if (verticalPos === "bottom") {
// tooltip이 아래에 있으면 tooltip의 위쪽 중앙
tooltipX = tooltipRect.left + tooltipRect.width / 2;
tooltipY = tooltipRect.top;
} else {
// center center일 때는 tooltip의 중앙
tooltipX = tooltipRect.left + tooltipRect.width / 2;
tooltipY = tooltipRect.top + tooltipRect.height / 2;
}
}
// SVG path 생성 (부드러운 곡선)
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const midX = (elementX + tooltipX) / 2;
const midY = (elementY + tooltipY) / 2;
// 곡선을 위한 제어점 계산
let controlX1, controlY1, controlX2, controlY2;
if (horizontalPos === "left" || horizontalPos === "right") {
// 수평 연결: 수직 방향으로 곡선
controlX1 = elementX + (tooltipX - elementX) * 0.3;
controlY1 = elementY;
controlX2 = tooltipX - (tooltipX - elementX) * 0.3;
controlY2 = tooltipY;
} else {
// 수직 연결: 수평 방향으로 곡선
controlX1 = elementX;
controlY1 = elementY + (tooltipY - elementY) * 0.3;
controlX2 = tooltipX;
controlY2 = tooltipY - (tooltipY - elementY) * 0.3;
}
const pathData = `M ${elementX} ${elementY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${tooltipX} ${tooltipY}`;
path.setAttribute("d", pathData);
path.setAttribute("stroke", "#FFF");
path.setAttribute("stroke-width", "1");
//path.setAttribute("stroke-dasharray", "2,2");
path.setAttribute("fill", "none");
//path.setAttribute("opacity", "0.6");
path.setAttribute("data-guide-selector", target.selector);
this.connectionLinesGroup.appendChild(path);
}
createKeywordElementConnection() {
try {
console.log("[createKeywordElementConnection] 시작");
if (!this.elementConnectionLinesGroup) {
console.warn("[MultiGuide] ❌ elementConnectionLinesGroup 없음");
return;
}
const element1 = this.domUtils?.$(".video-card:nth-child(3) .key-badge:first-child") || document.querySelector(".video-card:nth-child(3) .key-badge:first-child");
const element2 = this.domUtils?.$(".video-card:nth-child(5) .key-badge:first-child") || document.querySelector(".video-card:nth-child(5) .key-badge:first-child");
console.log("element1:", element1);
console.log("element2:", element2);
if (!element1 || !element2) {
console.warn("❌ 키워드 요소 없음");
return;
}
// 🔥 interest 툴팁의 index 찾기
const interestTarget = this.targets.find(t => t.class === "interest");
const interestIndex = this.targets.indexOf(interestTarget);
const animationDelay = interestIndex >= 0 ? interestIndex * 0.1 + 0.3 : 0.3;
const rect1 = element1.getBoundingClientRect();
const rect2 = element2.getBoundingClientRect();
const x1 = rect1.right;
const y1 = rect1.top + rect1.height / 2;
const x2 = rect2.left + rect2.width / 2;
const y2 = rect2.top;
const offset = 20;
const midX1 = x1 + offset;
const midY1 = y1;
const midX2 = x2;
const midY2 = y1;
// 두 키워드 요소를 연결하는 실선
const elementPathData = `M ${x1} ${y1} L ${midX1} ${midY1} L ${midX2} ${midY2} L ${x2} ${y2}`;
const elementPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
elementPath.setAttribute("d", elementPathData);
elementPath.setAttribute("stroke", "#FFF");
elementPath.setAttribute("stroke-width", "1.5");
elementPath.setAttribute("fill", "none");
elementPath.setAttribute("stroke-linecap", "round");
elementPath.setAttribute("stroke-linejoin", "round");
// 🔥 애니메이션 추가
elementPath.style.opacity = "0";
elementPath.style.animation = `connectionFadeIn 0.4s ease-out ${animationDelay}s forwards`;
this.elementConnectionLinesGroup.appendChild(elementPath);
console.log("✅ 키워드 요소 연결선 추가");
// interest 툴팁박스에서 연결선까지 실선으로 연결
const interestTooltip = this.labelsContainer.querySelector('.guide-tooltip-box.interest');
console.log("interestTooltip:", interestTooltip);
if (interestTooltip) {
const interestRect = interestTooltip.getBoundingClientRect();
const connectionMidX = midX2;
const connectionMidY = (midY2 + y2) / 2;
const interestX = interestRect.right;
const interestY = interestRect.top + interestRect.height / 2;
const interestPathData = `M ${interestX} ${interestY} L ${connectionMidX} ${interestY}`;
const interestPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
interestPath.setAttribute("d", interestPathData);
interestPath.setAttribute("stroke", "#FFF");
interestPath.setAttribute("stroke-width", "1.5");
interestPath.setAttribute("fill", "none");
interestPath.setAttribute("stroke-linecap", "round");
interestPath.setAttribute("stroke-linejoin", "round");
// 🔥 애니메이션 추가
interestPath.style.opacity = "0";
interestPath.style.animation = `connectionFadeIn 0.4s ease-out ${animationDelay}s forwards`;
this.elementConnectionLinesGroup.appendChild(interestPath);
console.log("✅ interest 툴팁 연결선 추가");
} else {
console.warn("⚠️ interest 툴팁 없음");
}
// 🔥 애니메이션 CSS 추가
if (!(this.domUtils?.$("#guide-connection-animation-styles") || document.getElementById("guide-connection-animation-styles"))) {
const style = this.domUtils?.createElement('style', { id: 'guide-connection-animation-styles' }) || document.createElement("style");
style.id = "guide-connection-animation-styles";
style.textContent = `
@keyframes connectionFadeIn {
from {
opacity: 0;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
}
to {
opacity: 0.8;
stroke-dasharray: 1000;
stroke-dashoffset: 0;
}
}
`;
document.head.appendChild(style);
}
} catch (error) {
this._handleError(error, 'createKeywordElementConnection');
}
}
createLabels() {
try {
if (!this.labelsContainer) {
console.warn("[MultiGuide] labelsContainer가 없습니다.");
return;
}
if (!this.targets || !Array.isArray(this.targets)) {
this._handleError(new Error('targets가 배열이 아닙니다.'), 'createLabels');
return;
}
this.targets.forEach((target, index) => {
try {
if (!target || !target.label) {
return;
}
const element = this.domUtils?.$(target.selector) || document.querySelector(target.selector);
if (!element) {
console.warn(`[MultiGuide] 요소를 찾을 수 없음: ${target.selector}`);
return;
}
const elementRect = element.getBoundingClientRect();
// 마스크된 영역의 rect 계산 (padding 포함)
const padding = target.padding || 0;
const maskRect = {
left: elementRect.left - padding,
top: elementRect.top - padding,
right: elementRect.right + padding,
bottom: elementRect.bottom + padding,
width: elementRect.width + padding * 2,
height: elementRect.height + padding * 2,
centerX: elementRect.left + elementRect.width / 2,
centerY: elementRect.top + elementRect.height / 2
};
// 기존 tooltipBox가 있는지 확인 (data-selector 속성으로 찾기)
let tooltipBox = this.labelsContainer.querySelector(
`[data-guide-selector="${CSS.escape(target.selector)}"]`
);
// 기존 것이 없으면 새로 생성
if (!tooltipBox) {
tooltipBox = document.createElement("div");
// 기본 클래스와 target의 class 속성 추가
tooltipBox.className = "guide-tooltip-box" + (target.class ? ` ${target.class}` : "");
tooltipBox.setAttribute("data-guide-selector", target.selector);
tooltipBox.style.cssText = `
animation: tooltipFadeIn 0.4s ease-out ${index * 0.1 + 0.3}s forwards;
opacity: 0;
`;
const title = document.createElement("div");
title.className = "guide-tooltip-title";
title.textContent = target.label;
tooltipBox.appendChild(title);
if (target.description) {
const description = document.createElement("div");
description.className = "guide-tooltip-description";
const lines = target.description.split('\n');
lines.forEach((line) => {
if (line.trim()) {
const p = document.createElement("p");
p.textContent = line;
description.appendChild(p);
}
});
tooltipBox.appendChild(description);
}
this.labelsContainer.appendChild(tooltipBox);
}
// 위치 계산 및 업데이트 (기존 것이든 새 것이든 위치는 업데이트)
// position 형식: "bottom", "top", "left", "right", "center", "center center", "center bottom" 등
const positionParts = (target.position || "").toLowerCase().split(/\s+/);
const verticalPos = positionParts.find(p => p === "top" || p === "bottom" || p === "center") ||
(elementRect.top < 150 ? "bottom" : "top");
const horizontalPos = positionParts.find(p => p === "left" || p === "right" || p === "center") || "center";
let left, top;
// 가로 위치 계산
if (horizontalPos === "left") {
left = maskRect.left - 10;
tooltipBox.setAttribute("data-position", "left");
tooltipBox.style.transform = "translateX(-100%)";
} else if (horizontalPos === "right") {
left = maskRect.right + 10;
tooltipBox.setAttribute("data-position", "right");
tooltipBox.style.transform = "translateX(0)";
} else {
// center: position이 "center" 또는 "center center"일 때는 요소의 중앙, 그 외는 마스크된 영역의 중앙
if (verticalPos === "center" || (positionParts.length === 1 && positionParts[0] === "center")) {
// 요소의 중앙에 tooltip 중앙이 맞도록
left = elementRect.left + elementRect.width / 2;
} else {
// 마스크된 영역의 중앙에 tooltip 중앙이 맞도록
left = maskRect.centerX;
}
tooltipBox.setAttribute("data-position", "center");
tooltipBox.style.transform = "translateX(-50%)";
}
// 세로 위치 계산
// learning 클래스인 경우 guide-arc-ellipse-stroke-path의 top 위치 사용
if (target.class === "learning" && this.arcEllipseStrokePath) {
const arcPathRect = this.arcEllipseStrokePath.getBoundingClientRect();
top = arcPathRect.top;
} else if (verticalPos === "center") {
// 요소의 중앙에 tooltip 중앙이 맞도록
const boxHeight = tooltipBox.offsetHeight || 100;
top = elementRect.top + elementRect.height / 2 - boxHeight / 2;
} else if (verticalPos === "bottom" || (elementRect.top < 150 && verticalPos !== "top")) {
top = maskRect.bottom + 15;
} else {
const boxHeight = tooltipBox.offsetHeight || 100;
top = maskRect.top - boxHeight - 15;
if (top < 10) {
top = maskRect.bottom + 15;
}
}
tooltipBox.style.left = `${left}px`;
tooltipBox.style.top = `${top}px`;
// 연결 라인 그리기
this.createConnectionLine(elementRect, maskRect, tooltipBox, target, horizontalPos, verticalPos);
} catch (error) {
this._handleError(error, 'createLabels.target', { index, target });
}
});
if (!(this.domUtils?.$("#guide-tooltip-styles") || document.getElementById("guide-tooltip-styles"))) {
const style = document.createElement("style");
style.id = "guide-tooltip-styles";
style.textContent = `
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.guide-tooltip-box[data-position="left"] {
animation-name: tooltipFadeInLeft;
}
.guide-tooltip-box[data-position="right"] {
animation-name: tooltipFadeInRight;
}
@keyframes tooltipFadeInLeft {
from {
opacity: 0;
transform: translateX(-100%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-100%) translateY(0);
}
}
@keyframes tooltipFadeInRight {
from {
opacity: 0;
transform: translateX(0) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(0) translateY(0);
}
}
`;
document.head.appendChild(style);
}
} catch (error) {
this._handleError(error, 'createLabels');
}
}
/**
* 리소스 정리 (이벤트 리스너 제거)
*/
destroy() {
try {
// 이벤트 리스너 제거
if (this.eventManager && this.listenerIds.length > 0) {
this.listenerIds.forEach(({ element, id }) => {
this.eventManager.off(element, id);
});
this.listenerIds = [];
}
// resize 타이머 정리
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
// 가이드 숨기기
this.hide();
// 참조 정리
this.guideWrap = null;
this.cutoutPath = null;
this.strokePath = null;
this.arcEllipseStrokePath = null;
this.arcStrokeMaskPath = null;
this.connectionLinesGroup = null;
this.elementConnectionLinesGroup = null;
this.bordersContainer = null;
this.labelsContainer = null;
this.targets = [];
this.isActive = false;
} catch (error) {
this._handleError(error, 'destroy');
}
}
}
// 초기화 함수
window.initMultiGuide = function(targets) {
// targets가 없으면 기본값 사용 또는 에러 처리
if (!targets || !Array.isArray(targets) || targets.length === 0) {
console.warn("[MultiGuide] targets가 제공되지 않았습니다. 기본 targets를 사용합니다.");
// 기본 targets를 사용하거나 에러를 발생시킬 수 있습니다.
// 여기서는 에러를 발생시키는 것이 더 안전합니다.
if (!targets) {
console.error("[MultiGuide] initMultiGuide는 targets 파라미터가 필요합니다.");
return;
}
}
if (window.multiGuide) {
console.log("MultiGuide가 이미 초기화되어 있습니다.");
return;
}
window.multiGuide = new MultiGuide(targets);
console.log("멀티 가이드 초기화 완료!");
let retryCount = 0;
const maxRetries = 50;
function autoShowGuide() {
retryCount++;
const requiredElements = targets.map(target => {
const el = document.querySelector(target.selector);
return { target, element: el, found: el !== null };
});
const foundCount = requiredElements.filter(r => r.found).length;
console.log(`[MultiGuide] 요소 확인 (${retryCount}회차): ${foundCount}/${targets.length}`);
const minRequiredElements = Math.max(3, Math.floor(targets.length * 0.5));
if (foundCount >= minRequiredElements) {
const gaugeTarget = targets.find(t => t.useGaugeArc);
if (gaugeTarget) {
const gaugeElement = document.getElementById("gauge");
if (gaugeElement) {
const hasGaugeContent = gaugeElement.children.length > 0 ||
gaugeElement.querySelector('path') !== null ||
gaugeElement.innerHTML.trim() !== '';
if (!hasGaugeContent && retryCount < 20) {
console.log("[MultiGuide] 게이지가 아직 초기화되지 않았습니다. 대기 중...");
if (retryCount < maxRetries) {
setTimeout(autoShowGuide, 200);
}
return;
}
}
}
try {
console.log("[MultiGuide] 가이드 실행 시도...");
window.multiGuide.show();
console.log("[MultiGuide] ✅ 멀티 가이드 자동 실행 완료!");
} catch (error) {
console.error("[MultiGuide] ❌ 실행 중 오류:", error);
}
} else {
if (retryCount < maxRetries) {
console.log(`[MultiGuide] 요소가 부족합니다. 재시도 중... (${retryCount}/${maxRetries})`);
setTimeout(autoShowGuide, 200);
} else {
console.warn("[MultiGuide] ⚠️ 자동 실행 시간 초과");
if (foundCount > 0) {
try {
window.multiGuide.show();
} catch (error) {
console.error("[MultiGuide] 실행 실패:", error);
}
}
}
}
}
if (document.readyState === "complete") {
setTimeout(autoShowGuide, 100);
} else {
window.addEventListener("load", () => {
setTimeout(autoShowGuide, 100);
});
}
};