Files
zenn 239f61c506 ESG 스크롤 복귀와 기술 역량 이미지 정렬·지도 표시 개선
- ESG 푸터에서 위로 스크롤하면 문서 영역이 먼저 복귀하도록 처리
- 섹션 전환 중 문서 내부 스크롤을 막아 상단 잘림 방지
- 기술 역량 이미지의 상단 여백과 가용 높이를 본문 기준으로 정렬
- 이미지 진입 애니메이션을 고려해 텍스트 겹침과 화면 이탈 방지
- 기술 역량 프로젝트 순서·설명 및 개별 이미지 배치 설정 정리
- 기술 역량 데이터에 맞춰 검색용 정적 HTML 동기화
- 지도 검색용 도로명 주소와 층·호수 안내를 분리해 건물 위치 핀 표시
- 지도 기본 확대 수준을 16으로 설정하고 한국어 표시 적용
- 회사 소개·조직도와 개인정보 동의 거부 안내 문구 정리

검증:
- JavaScript 구문 검사 및 git diff --check 통과
- ESG 세 문서의 푸터 복귀·내부 스크롤·키보드 이동 확인
- 기술 역량 29개 슬라이드의 데스크톱 이미지 정렬 및 모바일 이동 확인
- 본사·지사 지도 위치 핀, 상세 주소, 외부 지도 링크 확인
2026-09-30 17:55:10 +09:00

1121 lines
50 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 workspace = panel.closest(".esg-workspace");
// Restore the whole reader below the header before scrolling its document.
if (workspace && Math.abs(window.scrollY - sectionStop(workspace)) > 4) return false;
if (performance.now() < transitionUntil) 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>`,
},
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#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="닫기">&#215;</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="닫기">&#215;</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;
// Search for the building; floor/unit details can prevent a map pin from resolving.
const query = encodeURIComponent(trigger.dataset.mapQuery || address);
title.textContent = `${office} 오시는 길`;
frame.title = `${office} 위치 지도`;
frame.src = `https://maps.google.com/maps?q=${query}&z=16&hl=ko&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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#039;"
}[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 contentBounds = featureContent.getBoundingClientRect();
const contentRight = contentBounds.right + 32;
const contentTop = contentBounds.top;
const contentBottom = stageBounds.bottom - parseFloat(window.getComputedStyle(stage).paddingBottom);
[featureObject, featureObjectSecondary].forEach((element) => {
if (!element) return;
element.style.removeProperty("width");
element.style.removeProperty("height");
element.style.removeProperty("--object-fit-x");
element.style.removeProperty("--object-fit-y");
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);
const availableHeight = Math.max(0, contentBottom - contentTop);
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, availableHeight / naturalHeight);
element.style.width = `${naturalWidth * ratio}px`;
element.style.height = `${naturalHeight * ratio}px`;
const bounds = element.getBoundingClientRect();
const fittedStyle = window.getComputedStyle(element);
const [motionX = 0, motionY = 0] = fittedStyle.translate.split(" ").map(parseFloat);
const transform = new DOMMatrixReadOnly(fittedStyle.transform);
const [originX, originY] = fittedStyle.transformOrigin.split(" ").map(parseFloat);
// Measure the final position, excluding entry translation and scale.
const settledLeft = bounds.left - motionX + (transform.a - 1) * originX;
const settledTop = bounds.top - motionY + (transform.d - 1) * originY;
const settledBottom = settledTop + naturalHeight * ratio;
element.style.setProperty("--object-fit-x", `${Math.max(0, contentRight - settledLeft)}px`);
element.style.setProperty("--object-fit-y", `${Math.max(contentTop - settledTop,
Math.min(0, contentBottom - settledBottom))}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);
})();