메인: 홈 배경 이미지 교체 및 회사 주요 지표 영역 비노출 기술 역량: 카테고리를 AUTOMOTIVE > SOLUTION > SI > ROBOTICS 순으로 변경하고 키보드·이전/다음·자동 순환 순서를 동기화, PATENTS 및 전장 ETC 항목 비노출, 검색용 콘텐츠 재동기화 ESG: 안전보건 목표·담당자 정보·고충처리 문의 및 운영 정보 일부 비노출 문의하기: 서울 지사 명칭과 지도 레이블 통일 및 Office Hour 영역 비노출 공통: 페이지별 푸터 회사 정보 정리
1105 lines
49 KiB
JavaScript
1105 lines
49 KiB
JavaScript
(function () {
|
|
// 공통: 스크롤에 따른 헤더 표시와 모바일 메뉴 열기/닫기.
|
|
const header = document.querySelector("[data-header]");
|
|
const toggle = document.querySelector("[data-menu-toggle]");
|
|
const mobileMenu = document.querySelector("[data-mobile-menu]");
|
|
|
|
function updateHeader() {
|
|
if (!header) return;
|
|
header.classList.toggle("is-scrolled", window.scrollY > 20);
|
|
}
|
|
|
|
updateHeader();
|
|
window.addEventListener("scroll", updateHeader, { passive: true });
|
|
|
|
if (toggle && mobileMenu) {
|
|
mobileMenu.id = "mobile-navigation";
|
|
mobileMenu.inert = true;
|
|
toggle.setAttribute("aria-controls", mobileMenu.id);
|
|
toggle.setAttribute("aria-expanded", "false");
|
|
const menuLinks = [...mobileMenu.querySelectorAll("a")];
|
|
const menuLabels = ["Company", "Capabilities", "ESG", "Contact"];
|
|
menuLinks.forEach((link, index) => {
|
|
const title = document.createElement("span");
|
|
title.className = "mobile-menu-title";
|
|
title.textContent = link.textContent;
|
|
const label = document.createElement("span");
|
|
label.className = "mobile-menu-label";
|
|
label.textContent = menuLabels[index];
|
|
const arrow = document.createElement("img");
|
|
arrow.src = "assets/icons/chevron_forward.svg";
|
|
arrow.alt = "";
|
|
link.replaceChildren(label, title, arrow);
|
|
link.style.setProperty("--menu-index", index);
|
|
if (new URL(link.href).pathname === location.pathname) link.setAttribute("aria-current", "page");
|
|
});
|
|
const signature = document.createElement("div");
|
|
signature.className = "mobile-menu-signature";
|
|
signature.innerHTML = '<span>MOBIPIN TECHNOLOGY</span><a href="tel:0532140592">053-214-0592</a>';
|
|
mobileMenu.appendChild(signature);
|
|
const backgroundElements = [...document.querySelectorAll("main, .site-footer")];
|
|
const previousInert = new Map();
|
|
function setMenuOpen(isOpen, restoreFocus = false) {
|
|
mobileMenu.classList.toggle("is-open", isOpen);
|
|
document.body.classList.toggle("mobile-navigation-open", isOpen);
|
|
toggle.classList.toggle("is-open", isOpen);
|
|
mobileMenu.inert = !isOpen;
|
|
toggle.setAttribute("aria-label", isOpen ? "메뉴 닫기" : "메뉴 열기");
|
|
toggle.setAttribute("aria-expanded", String(isOpen));
|
|
backgroundElements.forEach((element) => {
|
|
if (isOpen) {
|
|
if (!previousInert.has(element)) previousInert.set(element, element.inert);
|
|
element.inert = true;
|
|
} else if (previousInert.has(element)) {
|
|
element.inert = previousInert.get(element);
|
|
previousInert.delete(element);
|
|
}
|
|
});
|
|
if (restoreFocus) toggle.focus();
|
|
}
|
|
toggle.addEventListener("click", () => setMenuOpen(!mobileMenu.classList.contains("is-open")));
|
|
mobileMenu.addEventListener("click", (event) => {
|
|
if (event.target.closest("a")) setMenuOpen(false);
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (!mobileMenu.classList.contains("is-open")) return;
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setMenuOpen(false, true);
|
|
} else if (event.key === "Tab") {
|
|
const focusable = [toggle, ...mobileMenu.querySelectorAll("a")];
|
|
const current = focusable.indexOf(document.activeElement);
|
|
event.preventDefault();
|
|
focusable[(current + (event.shiftKey ? -1 : 1) + focusable.length) % focusable.length].focus();
|
|
}
|
|
});
|
|
window.matchMedia("(min-width: 861px)").addEventListener("change", (event) => {
|
|
if (event.matches) setMenuOpen(false);
|
|
});
|
|
}
|
|
|
|
// 홈·회사소개·ESG: 데스크톱 섹션 이동, 트랙패드 관성 처리, 방향키 조작.
|
|
if (["home-body", "about-body", "esg-body"].some((name) => document.body.classList.contains(name))) {
|
|
const pageMode = window.matchMedia("(min-width: 861px) and (min-height: 700px)");
|
|
const sections = Array.from(document.querySelectorAll("main > section, .site-footer"));
|
|
const indicatorSections = sections.filter((section) => section.dataset.pageTitle);
|
|
const pageIndicator = createPageIndicator();
|
|
let transitionUntil = 0;
|
|
let lastWheelAt = 0;
|
|
let wheelDistance = 0;
|
|
let gestureConsumed = false;
|
|
let previousWheelDelta = 0;
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
|
|
function pageStops() {
|
|
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
|
return [...new Set(sections.map((section) => Math.round(Math.max(0,
|
|
Math.min(maxScroll, section.getBoundingClientRect().top + window.scrollY - headerHeight)
|
|
))))];
|
|
}
|
|
|
|
function sectionStop(section) {
|
|
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
|
return Math.round(Math.max(0,
|
|
Math.min(maxScroll, section.getBoundingClientRect().top + window.scrollY - headerHeight)
|
|
));
|
|
}
|
|
|
|
function usesPageNavigation() {
|
|
if (!pageMode.matches) return false;
|
|
if (document.querySelector(".site-dialog[open]")) return false;
|
|
if (mobileMenu && mobileMenu.classList.contains("is-open")) return;
|
|
// Long sections retain native scrolling so their content stays reachable.
|
|
const activeSection = sections.find((section) => {
|
|
const rect = section.getBoundingClientRect();
|
|
return rect.top <= 73 && rect.bottom > 73;
|
|
});
|
|
if (activeSection && activeSection.offsetHeight > window.innerHeight &&
|
|
!activeSection.classList.contains("home-hero")) return false;
|
|
return true;
|
|
}
|
|
|
|
function moveHomePage(direction) {
|
|
const now = performance.now();
|
|
if (now < transitionUntil) return;
|
|
const stops = pageStops();
|
|
const target = direction > 0
|
|
? stops.find((stop) => stop > window.scrollY + 4)
|
|
: [...stops].reverse().find((stop) => stop < window.scrollY - 4);
|
|
if (target === undefined) return;
|
|
transitionUntil = now + 900;
|
|
window.scrollTo({ top: target, behavior: reducedMotion.matches ? "instant" : "smooth" });
|
|
}
|
|
|
|
function createPageIndicator() {
|
|
if (!["home-body", "about-body"].some((name) => document.body.classList.contains(name))) return null;
|
|
if (indicatorSections.length < 2) return null;
|
|
const nav = document.createElement("nav");
|
|
nav.className = "page-dots";
|
|
nav.setAttribute("aria-label", "페이지 섹션");
|
|
indicatorSections.forEach((section, index) => {
|
|
const title = section.dataset.pageTitle;
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "page-dot";
|
|
button.dataset.index = String(index);
|
|
button.setAttribute("aria-label", `${title} 섹션으로 이동`);
|
|
button.innerHTML = `<span class="page-dot-mark" aria-hidden="true"></span><span class="page-dot-label">${title}</span>`;
|
|
button.addEventListener("click", () => {
|
|
transitionUntil = performance.now() + 900;
|
|
window.scrollTo({ top: sectionStop(section), behavior: reducedMotion.matches ? "instant" : "smooth" });
|
|
});
|
|
nav.appendChild(button);
|
|
});
|
|
document.body.appendChild(nav);
|
|
return nav;
|
|
}
|
|
|
|
function updatePageIndicator() {
|
|
if (!pageIndicator) return;
|
|
const viewportLine = (header ? header.getBoundingClientRect().height : 0) + Math.min(120, window.innerHeight * .24);
|
|
let activeIndex = 0;
|
|
indicatorSections.forEach((section, index) => {
|
|
const rect = section.getBoundingClientRect();
|
|
if (rect.top <= viewportLine && rect.bottom > viewportLine) activeIndex = index;
|
|
});
|
|
pageIndicator.querySelectorAll(".page-dot").forEach((button, index) => {
|
|
const isActive = index === activeIndex;
|
|
button.classList.toggle("is-active", isActive);
|
|
if (isActive) button.setAttribute("aria-current", "true");
|
|
else button.removeAttribute("aria-current");
|
|
});
|
|
}
|
|
|
|
function canScrollPolicy(target, direction) {
|
|
const panel = target.closest(".esg-policy-panel");
|
|
if (!panel || panel.hidden) return false;
|
|
const remaining = panel.scrollHeight - panel.clientHeight - panel.scrollTop;
|
|
return direction > 0 ? remaining > 2 : panel.scrollTop > 2;
|
|
}
|
|
|
|
window.addEventListener("wheel", (event) => {
|
|
if (!usesPageNavigation() || event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
|
|
if (canScrollPolicy(event.target, Math.sign(event.deltaY))) {
|
|
wheelDistance = 0;
|
|
gestureConsumed = false;
|
|
previousWheelDelta = 0;
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const now = performance.now();
|
|
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1);
|
|
if (!delta) return;
|
|
const directionChanged = previousWheelDelta && Math.sign(delta) !== Math.sign(previousWheelDelta);
|
|
const renewedGesture = Math.abs(delta) >= 4 && Math.abs(delta) > Math.abs(previousWheelDelta) * 1.8;
|
|
// Momentum can overlap a new swipe without a gap between wheel events.
|
|
if (now - lastWheelAt > 120 || directionChanged ||
|
|
(gestureConsumed && now >= transitionUntil && renewedGesture)) {
|
|
wheelDistance = 0;
|
|
gestureConsumed = false;
|
|
}
|
|
lastWheelAt = now;
|
|
previousWheelDelta = delta;
|
|
if (now < transitionUntil || gestureConsumed) return;
|
|
if (Math.sign(delta) !== Math.sign(wheelDistance)) wheelDistance = 0;
|
|
wheelDistance += delta;
|
|
if (Math.abs(wheelDistance) < 24) return;
|
|
gestureConsumed = true;
|
|
moveHomePage(Math.sign(wheelDistance));
|
|
}, { passive: false });
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
if (!usesPageNavigation() || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
|
|
event.target.closest("input, textarea, select, [contenteditable], [role='textbox'], [role='tab']")) return;
|
|
if (canScrollPolicy(event.target, event.key === "ArrowDown" ? 1 : -1)) return;
|
|
event.preventDefault();
|
|
if (!event.repeat) moveHomePage(event.key === "ArrowDown" ? 1 : -1);
|
|
});
|
|
|
|
window.addEventListener("scroll", updatePageIndicator, { passive: true });
|
|
window.addEventListener("resize", updatePageIndicator);
|
|
pageMode.addEventListener("change", updatePageIndicator);
|
|
updatePageIndicator();
|
|
}
|
|
|
|
// 공통: 푸터와 문의 폼에서 여는 개인정보 처리방침·사이트맵 모달.
|
|
const modalContent = {
|
|
privacy: {
|
|
title: "개인정보 처리방침",
|
|
body: `<p class="policy-intro">주식회사 모비핀테크놀러지는 문의 과정에서 수집되는 개인정보의 처리 기준과 보호에 관한 사항을 다음과 같이 안내합니다.</p>
|
|
<h3>1. 개인정보 수집 및 이용 목적</h3>
|
|
<p>주식회사 모비핀테크놀러지는 프로젝트 문의 확인, 상담 및 답변을 위해 문의자가 제공한 정보를 이용합니다.</p>
|
|
<h3>2. 수집하는 항목</h3>
|
|
<p>이름, 회사명, 이메일, 연락처, 문의 분야 및 문의 내용. 파일을 첨부한 경우 해당 파일에 포함된 정보가 함께 전달됩니다.</p>
|
|
<h3>3. 보유 및 이용 기간</h3>
|
|
<p>수집된 개인정보는 문의 처리 완료 후 1년간 보관하며, 보유 기간이 지나면 지체 없이 파기합니다.</p>
|
|
<h3>4. 정보 제공 및 처리 위탁</h3>
|
|
<p>제공된 정보는 문의 응대 목적으로 사용합니다. 외부 서비스에 처리를 위탁하는 경우 해당 서비스와 처리 범위를 운영 방침에 명시합니다.</p>
|
|
<h3>5. 동의 거부 및 정보 확인</h3>
|
|
<p>개인정보 수집 및 이용에 동의하지 않을 수 있습니다. 동의하지 않으면 온라인 문의를 전송할 수 없으며, 고객센터로 직접 연락할 수 있습니다. 제공한 정보의 확인, 수정, 삭제 요청도 고객센터로 문의할 수 있습니다.</p>
|
|
<h3>6. 개인정보 관련 문의</h3>
|
|
<p>주식회사 모비핀테크놀러지 · 고객센터 <a href="tel:0532140592">053-214-0592</a><br />평일 09:00 ~ 18:00</p>`,
|
|
},
|
|
sitemap: {
|
|
title: "사이트맵",
|
|
body: `<nav class="sitemap-groups" aria-label="전체 사이트 메뉴">
|
|
<section>
|
|
<h3><a href="index.html">홈</a></h3>
|
|
</section>
|
|
<section>
|
|
<h3><a href="about.html">회사소개</a></h3>
|
|
<a href="about.html#overview">회사 개요</a>
|
|
<a href="about.html#capability">기술 역량</a>
|
|
<a href="about.html#history">연혁</a>
|
|
<a href="about.html#organization">조직도</a>
|
|
<a href="about.html#partners">협력사</a>
|
|
</section>
|
|
<section>
|
|
<h3><a href="capabilities.html">기술 역량</a></h3>
|
|
<a href="capabilities.html#automotive">Automotive · 전장</a>
|
|
<a href="capabilities.html#robotics">Robotics · 로봇</a>
|
|
<a href="capabilities.html#solution">Solution · 솔루션</a>
|
|
<a href="capabilities.html#si">SI · 웹/앱 개발</a>
|
|
</section>
|
|
<section>
|
|
<h3><a href="esg.html">ESG</a></h3>
|
|
<a href="esg.html#esg">ESG 경영</a>
|
|
<a href="esg.html#ethics">윤리강령</a>
|
|
<a href="esg.html#safety">안전보건</a>
|
|
</section>
|
|
<section>
|
|
<h3><a href="contact.html">문의하기</a></h3>
|
|
</section>
|
|
</nav>`,
|
|
},
|
|
};
|
|
|
|
function prepareDialog(dialog) {
|
|
dialog.querySelector(".dialog-close")?.addEventListener("click", () => dialog.close());
|
|
dialog.addEventListener("click", (event) => {
|
|
const rect = dialog.getBoundingClientRect();
|
|
const clickedOutside = event.target === dialog && (
|
|
event.clientX < rect.left || event.clientX > rect.right ||
|
|
event.clientY < rect.top || event.clientY > rect.bottom
|
|
);
|
|
if (clickedOutside) dialog.close();
|
|
});
|
|
dialog.addEventListener("close", () => document.documentElement.classList.remove("has-site-dialog"));
|
|
}
|
|
|
|
Object.entries(modalContent).forEach(([key, content]) => {
|
|
const dialog = document.createElement("dialog");
|
|
dialog.className = "site-dialog";
|
|
dialog.id = `site-${key}`;
|
|
dialog.setAttribute("aria-labelledby", `${dialog.id}-title`);
|
|
dialog.innerHTML = `
|
|
<header class="site-dialog-header">
|
|
<h2 id="${dialog.id}-title">${content.title}</h2>
|
|
<button type="button" class="dialog-close" aria-label="닫기" title="닫기">×</button>
|
|
</header>
|
|
<div class="site-dialog-content">${content.body}</div>`;
|
|
document.body.appendChild(dialog);
|
|
prepareDialog(dialog);
|
|
dialog.querySelectorAll(".sitemap-groups a").forEach((link) => {
|
|
link.addEventListener("click", () => dialog.close());
|
|
});
|
|
document.querySelectorAll(`[data-site-modal="${key}"]`).forEach((trigger) => {
|
|
trigger.setAttribute("aria-haspopup", "dialog");
|
|
trigger.setAttribute("aria-controls", dialog.id);
|
|
trigger.addEventListener("click", () => {
|
|
dialog.showModal();
|
|
dialog.querySelector(".site-dialog-content").scrollTop = 0;
|
|
document.documentElement.classList.add("has-site-dialog");
|
|
});
|
|
});
|
|
});
|
|
|
|
const mapTriggers = document.querySelectorAll("[data-map-address]");
|
|
if (mapTriggers.length) {
|
|
const dialog = document.createElement("dialog");
|
|
dialog.className = "site-dialog location-dialog";
|
|
dialog.id = "office-map-dialog";
|
|
dialog.setAttribute("aria-labelledby", "office-map-title");
|
|
dialog.innerHTML = `
|
|
<header class="site-dialog-header">
|
|
<h2 id="office-map-title">오시는 길</h2>
|
|
<button type="button" class="dialog-close" aria-label="닫기" title="닫기">×</button>
|
|
</header>
|
|
<div class="site-dialog-content location-map-content">
|
|
<iframe class="location-map-frame" title="회사 위치 지도" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen></iframe>
|
|
<div class="location-map-meta">
|
|
<p data-location-address></p>
|
|
<a data-location-link href="https://www.google.com/maps" target="_blank" rel="noopener noreferrer">
|
|
Google 지도에서 크게 보기
|
|
<img src="assets/icons/arrow_outward.svg" alt="" />
|
|
</a>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(dialog);
|
|
prepareDialog(dialog);
|
|
|
|
const title = dialog.querySelector("#office-map-title");
|
|
const frame = dialog.querySelector(".location-map-frame");
|
|
const addressLabel = dialog.querySelector("[data-location-address]");
|
|
const mapLink = dialog.querySelector("[data-location-link]");
|
|
mapTriggers.forEach((trigger) => {
|
|
trigger.setAttribute("aria-haspopup", "dialog");
|
|
trigger.setAttribute("aria-controls", dialog.id);
|
|
trigger.addEventListener("click", () => {
|
|
const office = trigger.dataset.mapTitle;
|
|
const address = trigger.dataset.mapAddress;
|
|
const query = encodeURIComponent(address);
|
|
title.textContent = `${office} 오시는 길`;
|
|
frame.title = `${office} 위치 지도`;
|
|
frame.src = `https://maps.google.com/maps?q=${query}&z=16&output=embed`;
|
|
addressLabel.textContent = address;
|
|
mapLink.href = `https://www.google.com/maps/search/?api=1&query=${query}`;
|
|
dialog.showModal();
|
|
document.documentElement.classList.add("has-site-dialog");
|
|
});
|
|
});
|
|
dialog.addEventListener("close", () => frame.removeAttribute("src"));
|
|
}
|
|
|
|
// 문의하기: PocketBase에 문의와 첨부파일을 저장.
|
|
const form = document.querySelector("[data-contact-form]");
|
|
if (form) {
|
|
const consent = form.querySelector("[data-privacy-consent]");
|
|
const submitButton = form.querySelector("[data-contact-submit]");
|
|
const note = form.querySelector("[data-form-note]");
|
|
const fileInput = form.querySelector("[data-contact-files]");
|
|
const progressOverlay = document.querySelector("[data-contact-progress]");
|
|
const progressBar = progressOverlay?.querySelector("[data-contact-progressbar]");
|
|
const progressFill = progressOverlay?.querySelector("[data-contact-progress-fill]");
|
|
const progressLabel = progressOverlay?.querySelector("[data-contact-progress-label]");
|
|
const progressTitle = progressOverlay?.querySelector("[data-contact-progress-title]");
|
|
const submitLabel = submitButton?.textContent || "문의 전송";
|
|
const endpoint = "https://api.sori.studio/api/collections/mobipintech_website_inquiries/records";
|
|
const privacyVersion = "2026-09-21";
|
|
const maxAttachmentBytes = 100 * 1024 * 1024;
|
|
const maxAttachmentCount = 3;
|
|
const pageRegions = Array.from(document.querySelectorAll("header, main, footer"));
|
|
const previousInert = new Map();
|
|
let isSubmitting = false;
|
|
|
|
function updateConsent() {
|
|
if (submitButton) submitButton.disabled = isSubmitting || !consent || !consent.checked;
|
|
}
|
|
|
|
function showFormNote(message, state) {
|
|
if (!note) return;
|
|
note.hidden = false;
|
|
note.textContent = message;
|
|
note.classList.toggle("is-success", state === "success");
|
|
note.classList.toggle("is-error", state === "error");
|
|
}
|
|
|
|
function validateAttachments(showError = false) {
|
|
if (!fileInput) return true;
|
|
const files = Array.from(fileInput.files || []);
|
|
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
const countValid = files.length <= maxAttachmentCount;
|
|
const sizeValid = totalBytes <= maxAttachmentBytes;
|
|
const validationMessage = !countValid
|
|
? "첨부파일은 최대 3개까지 선택할 수 있습니다."
|
|
: !sizeValid ? "첨부파일 전체 용량은 100MB 이하여야 합니다." : "";
|
|
const isValid = countValid && sizeValid;
|
|
fileInput.setCustomValidity(validationMessage);
|
|
if (!isValid && showError) {
|
|
showFormNote(validationMessage, "error");
|
|
fileInput.reportValidity();
|
|
}
|
|
return isValid;
|
|
}
|
|
|
|
function setPageSubmitting(submitting) {
|
|
document.body.classList.toggle("is-contact-submitting", submitting);
|
|
pageRegions.forEach((region) => {
|
|
if (submitting) {
|
|
previousInert.set(region, region.inert);
|
|
region.inert = true;
|
|
} else if (previousInert.has(region)) {
|
|
region.inert = previousInert.get(region);
|
|
previousInert.delete(region);
|
|
}
|
|
});
|
|
if (progressOverlay) {
|
|
progressOverlay.hidden = !submitting;
|
|
if (submitting) progressOverlay.focus();
|
|
}
|
|
}
|
|
|
|
function updateProgress(percent, title = "문의 전송 중") {
|
|
const value = Math.max(0, Math.min(100, Math.round(percent)));
|
|
if (progressFill) progressFill.style.width = `${value}%`;
|
|
if (progressLabel) progressLabel.textContent = `${value}%`;
|
|
if (progressTitle) progressTitle.textContent = title;
|
|
if (progressBar) progressBar.setAttribute("aria-valuenow", String(value));
|
|
}
|
|
|
|
function uploadInquiry(formData) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = new XMLHttpRequest();
|
|
request.open("POST", endpoint);
|
|
request.setRequestHeader("Accept", "application/json");
|
|
request.upload.addEventListener("progress", (event) => {
|
|
if (!event.lengthComputable) return;
|
|
const percent = Math.min(95, (event.loaded / event.total) * 95);
|
|
updateProgress(percent, "문의 전송 중");
|
|
});
|
|
request.upload.addEventListener("load", () => updateProgress(95, "접수 처리 중"));
|
|
request.addEventListener("load", () => {
|
|
let response = null;
|
|
try {
|
|
response = JSON.parse(request.responseText);
|
|
} catch (_) {
|
|
// PocketBase가 JSON이 아닌 응답을 반환하면 상태 코드로 처리합니다.
|
|
}
|
|
if (request.status >= 200 && request.status < 300) {
|
|
resolve(response);
|
|
return;
|
|
}
|
|
const fieldMessage = response?.data?.attachments?.message;
|
|
const error = new Error(fieldMessage || response?.message || `문의 저장 실패 (${request.status})`);
|
|
error.isAttachmentError = Boolean(fieldMessage);
|
|
reject(error);
|
|
});
|
|
request.addEventListener("error", () => reject(new Error("문의 서버에 연결할 수 없습니다.")));
|
|
request.addEventListener("abort", () => reject(new Error("문의 전송이 중단되었습니다.")));
|
|
request.send(formData);
|
|
});
|
|
}
|
|
|
|
if (consent) consent.addEventListener("change", updateConsent);
|
|
if (fileInput) fileInput.addEventListener("change", () => validateAttachments(true));
|
|
form.addEventListener("reset", () => window.setTimeout(updateConsent, 0));
|
|
window.addEventListener("beforeunload", (event) => {
|
|
if (!isSubmitting) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
});
|
|
updateConsent();
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
if (isSubmitting || !consent || !consent.checked || !validateAttachments(true) || !form.reportValidity()) return;
|
|
|
|
isSubmitting = true;
|
|
updateConsent();
|
|
if (submitButton) submitButton.textContent = "전송 중...";
|
|
if (note) note.hidden = true;
|
|
updateProgress(0);
|
|
setPageSubmitting(true);
|
|
|
|
const formData = new FormData(form);
|
|
formData.set("privacyConsent", "true");
|
|
formData.set("privacyVersion", privacyVersion);
|
|
formData.set("status", "new");
|
|
|
|
try {
|
|
await uploadInquiry(formData);
|
|
updateProgress(100, "접수 완료");
|
|
await new Promise((resolve) => window.setTimeout(resolve, 350));
|
|
form.reset();
|
|
showFormNote("문의가 정상적으로 접수되었습니다. 담당자가 확인 후 연락드리겠습니다.", "success");
|
|
} catch (error) {
|
|
console.error("Contact form submission failed:", error);
|
|
const message = error.isAttachmentError
|
|
? "첨부파일이 서버의 허용 용량이나 형식 제한을 초과했습니다. 파일을 확인해 주세요."
|
|
: "문의 전송에 실패했습니다. 잠시 후 다시 시도하거나 고객센터 053-214-0592로 연락해 주세요.";
|
|
showFormNote(message, "error");
|
|
} finally {
|
|
isSubmitting = false;
|
|
setPageSubmitting(false);
|
|
if (submitButton) submitButton.textContent = submitLabel;
|
|
updateConsent();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ESG: 문서 선택, 탭 방향키 조작, URL 해시와 선택 상태 동기화.
|
|
const policyTabs = Array.from(document.querySelectorAll("[data-esg-tab]"));
|
|
if (policyTabs.length) {
|
|
const workspace = document.querySelector(".esg-workspace");
|
|
const tabList = document.querySelector(".esg-policy-tabs");
|
|
function selectPolicy(id, navigate = false) {
|
|
if (!policyTabs.some((tab) => tab.dataset.esgTab === id)) return;
|
|
policyTabs.forEach((tab) => {
|
|
const selected = tab.dataset.esgTab === id;
|
|
tab.setAttribute("aria-selected", String(selected));
|
|
tab.tabIndex = selected ? 0 : -1;
|
|
const panel = document.getElementById(tab.dataset.esgTab);
|
|
panel.hidden = !selected;
|
|
if (selected) panel.scrollTop = 0;
|
|
});
|
|
if (navigate) workspace.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
policyTabs.forEach((tab, index) => {
|
|
tab.addEventListener("click", () => {
|
|
selectPolicy(tab.dataset.esgTab);
|
|
history.replaceState(null, "", `#${tab.dataset.esgTab}`);
|
|
});
|
|
tab.addEventListener("keydown", (event) => {
|
|
let next;
|
|
if (["ArrowDown", "ArrowRight"].includes(event.key)) next = (index + 1) % policyTabs.length;
|
|
if (["ArrowUp", "ArrowLeft"].includes(event.key)) next = (index + policyTabs.length - 1) % policyTabs.length;
|
|
if (event.key === "Home") next = 0;
|
|
if (event.key === "End") next = policyTabs.length - 1;
|
|
if (next === undefined) return;
|
|
event.preventDefault();
|
|
policyTabs[next].click();
|
|
policyTabs[next].focus();
|
|
});
|
|
});
|
|
const mobilePolicies = window.matchMedia("(max-width: 860px)");
|
|
function updateTabOrientation() {
|
|
tabList.setAttribute("aria-orientation", mobilePolicies.matches ? "horizontal" : "vertical");
|
|
}
|
|
updateTabOrientation();
|
|
mobilePolicies.addEventListener("change", updateTabOrientation);
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
if (!["ArrowLeft", "ArrowRight"].includes(event.key) || mobilePolicies.matches) return;
|
|
if (event.repeat || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
|
|
document.querySelector(".site-dialog[open]") ||
|
|
mobileMenu?.classList.contains("is-open") ||
|
|
event.target.closest("input, textarea, select, [contenteditable], [role='textbox'], [data-esg-tab]")) return;
|
|
const bounds = workspace.getBoundingClientRect();
|
|
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
|
if (bounds.bottom <= headerHeight || bounds.top >= window.innerHeight) return;
|
|
event.preventDefault();
|
|
const current = policyTabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true");
|
|
const direction = event.key === "ArrowRight" ? 1 : -1;
|
|
policyTabs[(current + direction + policyTabs.length) % policyTabs.length].click();
|
|
});
|
|
|
|
selectPolicy(location.hash.slice(1));
|
|
window.addEventListener("hashchange", () => selectPolicy(location.hash.slice(1), true));
|
|
}
|
|
|
|
// 기술 역량: capabilities-data.js의 프로젝트로 슬라이드와 목록을 구성.
|
|
const projects = window.CAPABILITY_PROJECTS || [];
|
|
const grid = document.querySelector("[data-project-navigation]");
|
|
const featureBg = document.querySelector("[data-feature-bg]");
|
|
const featureObject = document.querySelector("[data-feature-object]");
|
|
const featureObjectSecondary = document.querySelector("[data-feature-object-secondary]");
|
|
const featureContent = document.querySelector(".feature-content");
|
|
const featureTable = document.querySelector("[data-feature-table]");
|
|
const featureCategory = document.querySelector("[data-feature-category]");
|
|
const featureTitle = document.querySelector("[data-feature-title]");
|
|
const featureSubtitle = document.querySelector("[data-feature-subtitle]");
|
|
const featureDescription = document.querySelector("[data-feature-description]");
|
|
const filterButtons = document.querySelectorAll("[data-filter]");
|
|
const autoplayButtons = document.querySelectorAll("[data-autoplay-toggle]");
|
|
const prevButtons = document.querySelectorAll("[data-slide-prev]");
|
|
const nextButtons = document.querySelectorAll("[data-slide-next]");
|
|
const slideCounter = document.querySelector("[data-slide-counter]");
|
|
const capabilitiesWorkbench = document.querySelector(".capabilities-workbench");
|
|
|
|
if (!grid || !projects.length) return;
|
|
|
|
const labels = {
|
|
automotive: "Automotive",
|
|
solution: "Solution",
|
|
si: "SI",
|
|
robotics: "Robotics",
|
|
};
|
|
const orderedFilters = Array.from(filterButtons, (button) => button.dataset.filter)
|
|
.filter((type) => labels[type] && projects.some((item) => item.type === type));
|
|
const hash = location.hash.replace("#", "");
|
|
let currentFilter = orderedFilters.includes(hash) ? hash : orderedFilters[0];
|
|
let currentProjects = [];
|
|
let currentIndex = 0;
|
|
let wheelLocked = false;
|
|
let autoplayTimer = 0;
|
|
// 자동 재생 초기 상태: true = 멈춤, false = 페이지를 열면 자동 재생.
|
|
let autoplayPaused = false;
|
|
// 슬라이드 전환 간격(ms). 9000 = 9초이며 버튼의 진행 표시도 이 값을 사용.
|
|
const autoplayDelay = 9000;
|
|
|
|
// 기술 역량: 재생/멈춤 버튼, 진행 표시, 자동 전환 타이머.
|
|
function updateAutoplayButtons(resetProgress = false) {
|
|
autoplayButtons.forEach((button) => {
|
|
const label = button.querySelector("[data-autoplay-label]");
|
|
const icon = button.querySelector("[data-autoplay-icon]");
|
|
const isRunning = !autoplayPaused && projects.length > 1;
|
|
if (label) label.textContent = autoplayPaused ? "재생" : "멈춤";
|
|
if (icon) icon.src = autoplayPaused ? "assets/icons/play_arrow.svg" : "assets/icons/pause.svg";
|
|
button.setAttribute("aria-label", autoplayPaused ? "자동 전환 재생" : "자동 전환 멈춤");
|
|
button.setAttribute("aria-pressed", autoplayPaused ? "true" : "false");
|
|
button.style.setProperty("--autoplay-duration", `${autoplayDelay}ms`);
|
|
button.classList.remove("is-running");
|
|
button.classList.toggle("is-paused", autoplayPaused);
|
|
if (resetProgress) {
|
|
void button.offsetWidth;
|
|
}
|
|
button.classList.toggle("is-running", isRunning);
|
|
});
|
|
}
|
|
|
|
function stopAutoplay() {
|
|
window.clearTimeout(autoplayTimer);
|
|
autoplayTimer = 0;
|
|
}
|
|
|
|
function scheduleAutoplay() {
|
|
stopAutoplay();
|
|
updateAutoplayButtons(true);
|
|
if (autoplayPaused || projects.length < 2) return;
|
|
autoplayTimer = window.setTimeout(() => {
|
|
if (document.querySelector(".site-dialog[open]")) {
|
|
scheduleAutoplay();
|
|
return;
|
|
}
|
|
moveFeature(1);
|
|
scheduleAutoplay();
|
|
}, autoplayDelay);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "").replace(/[&<>"']/g, (match) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'"
|
|
}[match]));
|
|
}
|
|
|
|
function optimizedCapabilityImage(source) {
|
|
if (!source) return "";
|
|
return source
|
|
.replace("assets/images/", "assets/images-webp/")
|
|
.replace(/\.(?:png|jpe?g)$/i, ".webp");
|
|
}
|
|
|
|
let adjacentPreloadTimer = 0;
|
|
const preloadedCapabilityImages = new Map();
|
|
function preloadProjectAssets(item) {
|
|
if (!item) return;
|
|
[item.image, item.objectImage, item.objectImageSecondary].forEach((source) => {
|
|
const optimized = optimizedCapabilityImage(source);
|
|
if (!optimized || preloadedCapabilityImages.has(optimized)) return;
|
|
const image = new Image();
|
|
image.decoding = "async";
|
|
image.src = optimized;
|
|
preloadedCapabilityImages.set(optimized, image);
|
|
});
|
|
}
|
|
|
|
function scheduleAdjacentPreload() {
|
|
window.clearTimeout(adjacentPreloadTimer);
|
|
adjacentPreloadTimer = window.setTimeout(() => {
|
|
const categories = projectCategories();
|
|
const categoryIndex = categories.indexOf(currentFilter);
|
|
const previousCategory = categories[(categoryIndex - 1 + categories.length) % categories.length];
|
|
const nextCategory = categories[(categoryIndex + 1) % categories.length];
|
|
const previousProjects = projects.filter((item) => item.type === previousCategory);
|
|
const previous = currentProjects[currentIndex - 1] || previousProjects[previousProjects.length - 1];
|
|
const next = currentProjects[currentIndex + 1] ||
|
|
projects.find((item) => item.type === nextCategory);
|
|
preloadProjectAssets(previous);
|
|
preloadProjectAssets(next);
|
|
}, 1000);
|
|
}
|
|
|
|
// 기술 역량: 전장 기타 프로젝트 표.
|
|
function renderFeatureTable(item) {
|
|
if (!featureTable) return;
|
|
const rows = item.tableRows || [];
|
|
featureTable.hidden = item.layout !== "table";
|
|
if (item.layout !== "table") {
|
|
featureTable.innerHTML = "";
|
|
return;
|
|
}
|
|
featureTable.innerHTML = `
|
|
<table class="feature-summary-table">
|
|
<thead>
|
|
<tr>
|
|
<th>${escapeHtml(item.tableHeaders?.[0] || "사업명")}</th>
|
|
<th>${escapeHtml(item.tableHeaders?.[1] || "사업기간")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows.map((row) => `
|
|
<tr>
|
|
<td>${escapeHtml(row[0])}</td>
|
|
<td>${escapeHtml(row[1])}</td>
|
|
</tr>
|
|
`).join("")}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
// 기술 역량: 모바일 특허증 본문과 오브젝트의 겹침 방지·등장/퇴장 처리.
|
|
const objectTransitions = new WeakMap();
|
|
const certificateGallery = document.createElement("div");
|
|
certificateGallery.className = "mobile-certificate-gallery";
|
|
certificateGallery.hidden = true;
|
|
if (featureContent) featureContent.append(certificateGallery);
|
|
|
|
function fitFeatureObjects() {
|
|
if (!capabilitiesWorkbench || !featureContent) return;
|
|
const stage = capabilitiesWorkbench.querySelector(".workbench-stage");
|
|
if (!stage) return;
|
|
const desktop = !window.matchMedia("(max-width: 860px)").matches;
|
|
const hasDescription = !capabilitiesWorkbench.classList.contains("is-table-slide") &&
|
|
!capabilitiesWorkbench.classList.contains("is-certification-slide");
|
|
const stageBounds = stage.getBoundingClientRect();
|
|
if (desktop && capabilitiesWorkbench.classList.contains("is-certification-slide") && featureTitle) {
|
|
const titleBottom = featureTitle.getBoundingClientRect().bottom - stageBounds.top;
|
|
capabilitiesWorkbench.style.setProperty("--certificate-top", `${titleBottom + 24}px`);
|
|
}
|
|
const contentRight = featureContent.getBoundingClientRect().right + 32;
|
|
[featureObject, featureObjectSecondary].forEach((element) => {
|
|
if (!element) return;
|
|
element.style.removeProperty("width");
|
|
element.style.removeProperty("height");
|
|
element.style.removeProperty("--object-fit-x");
|
|
if (!desktop || !hasDescription || !element.getAttribute("src")) return;
|
|
const style = window.getComputedStyle(element);
|
|
const width = parseFloat(style.width);
|
|
const height = parseFloat(style.height);
|
|
const available = Math.max(0, stageBounds.right - contentRight);
|
|
if (!width || !height) return;
|
|
// Fit the actual image ratio, not the potentially letterboxed CSS rectangle.
|
|
const naturalWidth = element.naturalWidth;
|
|
const naturalHeight = element.naturalHeight;
|
|
if (!naturalWidth || !naturalHeight) return;
|
|
const ratio = Math.min(width / naturalWidth, height / naturalHeight, available / naturalWidth);
|
|
element.style.width = `${naturalWidth * ratio}px`;
|
|
element.style.height = `${naturalHeight * ratio}px`;
|
|
const bounds = element.getBoundingClientRect();
|
|
const motionX = parseFloat(window.getComputedStyle(element).translate) || 0;
|
|
const settledLeft = bounds.left - motionX;
|
|
element.style.setProperty("--object-fit-x", `${Math.max(0, contentRight - settledLeft)}px`);
|
|
});
|
|
}
|
|
|
|
if (capabilitiesWorkbench && featureContent) {
|
|
const objectFitObserver = new ResizeObserver(fitFeatureObjects);
|
|
objectFitObserver.observe(featureContent);
|
|
objectFitObserver.observe(capabilitiesWorkbench);
|
|
window.addEventListener("resize", fitFeatureObjects);
|
|
[featureObject, featureObjectSecondary].forEach((element) => {
|
|
if (element) element.addEventListener("load", fitFeatureObjects);
|
|
});
|
|
}
|
|
|
|
function setFeature(item, index = currentIndex) {
|
|
if (!item) return;
|
|
currentIndex = index;
|
|
if (capabilitiesWorkbench) {
|
|
capabilitiesWorkbench.classList.toggle("is-table-slide", item.layout === "table");
|
|
capabilitiesWorkbench.classList.toggle("is-certification-slide", item.layout === "certifications");
|
|
}
|
|
if (featureContent) {
|
|
featureContent.hidden = item.layout === "table";
|
|
featureContent.scrollTop = 0;
|
|
}
|
|
certificateGallery.hidden = item.layout !== "certifications";
|
|
certificateGallery.replaceChildren();
|
|
if (item.layout === "certifications") {
|
|
[item.objectImage, item.objectImageSecondary].filter(Boolean).forEach((src, index) => {
|
|
const image = document.createElement("img");
|
|
image.src = optimizedCapabilityImage(src);
|
|
image.alt = `${item.title} ${index + 1}`;
|
|
certificateGallery.append(image);
|
|
});
|
|
}
|
|
renderFeatureTable(item);
|
|
if (featureTable) {
|
|
featureTable.scrollTop = 0;
|
|
featureTable.scrollLeft = 0;
|
|
}
|
|
if (featureBg) {
|
|
const background = optimizedCapabilityImage(item.image);
|
|
featureBg.style.backgroundImage = background ? `url("${background}")` : "none";
|
|
}
|
|
const setFeatureObject = async (element, image, placement, layout, motion) => {
|
|
if (!element) return;
|
|
const token = {};
|
|
objectTransitions.set(element, token);
|
|
const wasVisible = element.classList.contains("is-visible");
|
|
element.classList.remove("is-visible");
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const preload = new Image();
|
|
if (image) preload.src = image;
|
|
await Promise.all([
|
|
new Promise((resolve) => window.setTimeout(resolve, wasVisible && !reducedMotion ? 280 : 0)),
|
|
image ? preload.decode().catch(() => {}) : Promise.resolve(),
|
|
]);
|
|
if (objectTransitions.get(element) !== token) return;
|
|
// Change placement only while hidden, so anchors never become motion paths.
|
|
element.className = element === featureObject
|
|
? "feature-object is-repositioning"
|
|
: "feature-object feature-object-secondary is-repositioning";
|
|
element.removeAttribute("style");
|
|
if (!image) {
|
|
element.removeAttribute("src");
|
|
return;
|
|
}
|
|
element.src = image;
|
|
if (motion === "bottom") element.classList.add("is-motion-bottom");
|
|
if (placement) element.classList.add(`is-${placement}`);
|
|
if (layout) {
|
|
element.classList.add("is-custom-layout");
|
|
const properties = {
|
|
x: "--object-x", y: "--object-y",
|
|
anchorX: "--object-anchor-x", anchorY: "--object-anchor-y",
|
|
width: "--object-width", height: "--object-height",
|
|
maxWidth: "--object-max-width", maxHeight: "--object-max-height",
|
|
};
|
|
Object.entries(properties).forEach(([key, property]) => {
|
|
if (layout[key] !== undefined) element.style.setProperty(property, layout[key]);
|
|
});
|
|
}
|
|
fitFeatureObjects();
|
|
void element.offsetWidth;
|
|
element.classList.remove("is-repositioning");
|
|
window.requestAnimationFrame(() => {
|
|
if (objectTransitions.get(element) === token) element.classList.add("is-visible");
|
|
});
|
|
};
|
|
setFeatureObject(
|
|
featureObject,
|
|
optimizedCapabilityImage(item.objectImage),
|
|
item.objectPlacement,
|
|
item.objectLayout,
|
|
item.objectMotion,
|
|
);
|
|
setFeatureObject(
|
|
featureObjectSecondary,
|
|
optimizedCapabilityImage(item.objectImageSecondary),
|
|
item.objectPlacementSecondary,
|
|
item.objectLayoutSecondary,
|
|
item.objectMotionSecondary,
|
|
);
|
|
if (featureCategory) featureCategory.textContent = item.category;
|
|
if (featureTitle) featureTitle.textContent = item.title;
|
|
if (featureSubtitle) featureSubtitle.textContent = item.subtitle || item.scope || "";
|
|
if (featureDescription) featureDescription.textContent = item.description;
|
|
if (slideCounter) {
|
|
slideCounter.textContent = `${String(currentIndex + 1).padStart(2, "0")} / ${String(currentProjects.length).padStart(2, "0")}`;
|
|
}
|
|
|
|
grid.querySelectorAll(".project-link").forEach((button) => {
|
|
button.classList.toggle("is-active", button.dataset.id === item.id);
|
|
button.setAttribute("aria-current", button.dataset.id === item.id ? "true" : "false");
|
|
});
|
|
scheduleAdjacentPreload();
|
|
}
|
|
|
|
// 기술 역량: 이전/다음 이동. 카테고리 끝에서는 다음 카테고리로 순환.
|
|
function projectCategories() {
|
|
return orderedFilters;
|
|
}
|
|
|
|
function moveCategory(direction, startAtEnd = false) {
|
|
const categories = projectCategories();
|
|
if (!categories.length) return;
|
|
const categoryIndex = categories.indexOf(currentFilter);
|
|
currentFilter = categories[(categoryIndex + direction + categories.length) % categories.length];
|
|
history.replaceState(null, "", `#${currentFilter}`);
|
|
const lastIndex = projects.filter((item) => item.type === currentFilter).length - 1;
|
|
renderProjects(currentFilter, startAtEnd ? lastIndex : 0);
|
|
}
|
|
|
|
function moveFeature(direction) {
|
|
if (!currentProjects.length) return;
|
|
const next = currentIndex + direction;
|
|
if (next < 0 || next >= currentProjects.length) {
|
|
moveCategory(direction, direction < 0);
|
|
return;
|
|
}
|
|
setFeature(currentProjects[next], next);
|
|
}
|
|
|
|
function renderProjects(filter, startIndex = 0) {
|
|
const filtered = filter === "all" ? projects : projects.filter((item) => item.type === filter);
|
|
currentProjects = filtered;
|
|
currentIndex = startIndex;
|
|
grid.innerHTML = "";
|
|
|
|
const progress = document.createElement("div");
|
|
progress.className = "project-progress";
|
|
const menu = document.createElement("div");
|
|
menu.className = "project-menu";
|
|
const list = document.createElement("div");
|
|
list.className = "project-menu-list";
|
|
list.setAttribute("aria-label", `${labels[filter]} 프로젝트 목록`);
|
|
const categories = projectCategories();
|
|
const categoryIndex = categories.indexOf(filter);
|
|
function categoryLink(direction) {
|
|
const type = categories[(categoryIndex + direction + categories.length) % categories.length];
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "project-category-link";
|
|
const categoryName = `<strong>${escapeHtml(labels[type])}</strong>`;
|
|
const categoryDirection = `<span class="project-category-direction"><img src="assets/icons/chevron_${direction < 0 ? "backward" : "forward"}.svg" alt="" /><span>${direction < 0 ? "이전 분야" : "다음 분야"}</span></span>`;
|
|
button.classList.add(direction < 0 ? "is-previous" : "is-next");
|
|
button.innerHTML = direction < 0 ? categoryDirection + categoryName : categoryName + categoryDirection;
|
|
button.addEventListener("click", () => {
|
|
moveCategory(direction);
|
|
grid.classList.add("is-closed-after-click");
|
|
});
|
|
return button;
|
|
}
|
|
menu.append(categoryLink(-1), list, categoryLink(1));
|
|
grid.append(progress, menu);
|
|
|
|
filtered.forEach((item, index) => {
|
|
const thumbnail = (source, className) => source
|
|
? `<img class="${className}" data-thumbnail-src="${escapeHtml(source.replace("assets/images/", "assets/thumbnails/"))}" alt="" loading="lazy" decoding="async" />`
|
|
: "";
|
|
const preview = `${thumbnail(item.image, "thumbnail-background")}<span class="project-title">${escapeHtml(item.title)}</span>`;
|
|
for (const [container, className, html] of [
|
|
[progress, "dot-button project-link", '<span class="dot-mark"></span>'],
|
|
[list, "project-preview project-link", preview],
|
|
]) {
|
|
const card = document.createElement("button");
|
|
card.type = "button";
|
|
card.className = className;
|
|
card.dataset.id = item.id;
|
|
card.setAttribute("aria-label", item.title);
|
|
card.innerHTML = html;
|
|
card.addEventListener("click", (event) => {
|
|
setFeature(item, index);
|
|
event.currentTarget.blur();
|
|
grid.classList.add("is-closed-after-click");
|
|
scheduleAutoplay();
|
|
});
|
|
container.appendChild(card);
|
|
}
|
|
});
|
|
|
|
filterButtons.forEach((button) => {
|
|
button.classList.toggle("is-active", button.dataset.filter === filter);
|
|
});
|
|
|
|
setFeature(filtered[startIndex], startIndex);
|
|
scheduleAutoplay();
|
|
}
|
|
|
|
// 기술 역량: 카테고리 선택과 URL 해시 변경.
|
|
filterButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
currentFilter = button.dataset.filter;
|
|
history.replaceState(null, "", `#${currentFilter}`);
|
|
renderProjects(currentFilter);
|
|
});
|
|
});
|
|
|
|
window.addEventListener("hashchange", () => {
|
|
const filter = location.hash.slice(1);
|
|
if (orderedFilters.includes(filter) && filter !== currentFilter) {
|
|
currentFilter = filter;
|
|
renderProjects(filter);
|
|
}
|
|
});
|
|
|
|
grid.addEventListener("mouseleave", () => {
|
|
grid.classList.remove("is-closed-after-click");
|
|
});
|
|
grid.addEventListener("focusout", (event) => {
|
|
if (!grid.contains(event.relatedTarget)) grid.classList.remove("is-closed-after-click");
|
|
});
|
|
// 썸네일은 목록을 처음 열 때만 로드. 목록의 휠 입력은 슬라이드로 전달하지 않음.
|
|
function loadProjectThumbnails() {
|
|
grid.querySelectorAll("[data-thumbnail-src]").forEach((image) => {
|
|
image.src = image.dataset.thumbnailSrc;
|
|
image.removeAttribute("data-thumbnail-src");
|
|
});
|
|
}
|
|
grid.addEventListener("mouseenter", loadProjectThumbnails);
|
|
grid.addEventListener("focusin", loadProjectThumbnails);
|
|
grid.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
|
|
grid.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Escape") return;
|
|
grid.classList.add("is-closed-after-click");
|
|
document.activeElement?.blur();
|
|
});
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key) ||
|
|
window.matchMedia("(max-width: 860px)").matches) return;
|
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
|
|
document.querySelector(".site-dialog[open]") ||
|
|
mobileMenu?.classList.contains("is-open") ||
|
|
event.target.closest("input, textarea, select, [contenteditable], [role='textbox']")) return;
|
|
event.preventDefault();
|
|
if (event.repeat) return;
|
|
grid.classList.add("is-closed-after-click");
|
|
if (event.key === "ArrowUp") moveFeature(-1);
|
|
if (event.key === "ArrowDown") moveFeature(1);
|
|
if (event.key === "ArrowLeft") moveCategory(-1);
|
|
if (event.key === "ArrowRight") moveCategory(1);
|
|
scheduleAutoplay();
|
|
});
|
|
|
|
// 기술 역량: 본문 스크롤 우선, 끝에서 잠깐 멈춘 뒤 새 휠 입력으로 슬라이드 전환.
|
|
if (capabilitiesWorkbench) {
|
|
let contentGestureActive = false;
|
|
let lastContentWheelAt = 0;
|
|
capabilitiesWorkbench.addEventListener("wheel", (event) => {
|
|
if (window.matchMedia("(max-width: 860px)").matches) return;
|
|
if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
|
|
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1);
|
|
if (!delta) return;
|
|
const now = performance.now();
|
|
// Let nested content scroll until it reaches the boundary in this direction.
|
|
for (let panel = event.target; panel && panel !== capabilitiesWorkbench; panel = panel.parentElement) {
|
|
const overflowY = window.getComputedStyle(panel).overflowY;
|
|
if (!["auto", "scroll"].includes(overflowY) || panel.scrollHeight <= panel.clientHeight + 2) continue;
|
|
const remaining = panel.scrollHeight - panel.clientHeight - panel.scrollTop;
|
|
if (delta > 0 ? remaining > 2 : panel.scrollTop > 2) {
|
|
contentGestureActive = true;
|
|
lastContentWheelAt = now;
|
|
return;
|
|
}
|
|
}
|
|
event.preventDefault();
|
|
// Reaching the edge ends this gesture; advancing needs a fresh gesture after a pause.
|
|
if (contentGestureActive && now - lastContentWheelAt < 300) {
|
|
lastContentWheelAt = now;
|
|
return;
|
|
}
|
|
contentGestureActive = false;
|
|
if (wheelLocked || Math.abs(delta) < 8) return;
|
|
wheelLocked = true;
|
|
moveFeature(delta > 0 ? 1 : -1);
|
|
scheduleAutoplay();
|
|
window.setTimeout(() => {
|
|
wheelLocked = false;
|
|
}, 520);
|
|
}, { passive: false });
|
|
}
|
|
|
|
// 기술 역량: 모바일 이전/다음과 공통 재생/멈춤 버튼.
|
|
prevButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
moveFeature(-1);
|
|
scheduleAutoplay();
|
|
});
|
|
});
|
|
|
|
nextButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
moveFeature(1);
|
|
scheduleAutoplay();
|
|
});
|
|
});
|
|
|
|
autoplayButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
autoplayPaused = !autoplayPaused;
|
|
scheduleAutoplay();
|
|
});
|
|
});
|
|
|
|
renderProjects(currentFilter);
|
|
})();
|