(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 = 'MOBIPIN TECHNOLOGY053-214-0592'; 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 = `${title}`; 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: `

주식회사 모비핀테크놀러지는 문의 과정에서 수집되는 개인정보의 처리 기준과 보호에 관한 사항을 다음과 같이 안내합니다.

1. 개인정보 수집 및 이용 목적

주식회사 모비핀테크놀러지는 프로젝트 문의 확인, 상담 및 답변을 위해 문의자가 제공한 정보를 이용합니다.

2. 수집하는 항목

이름, 회사명, 이메일, 연락처, 문의 분야 및 문의 내용. 파일을 첨부한 경우 해당 파일에 포함된 정보가 함께 전달됩니다.

3. 보유 및 이용 기간

수집된 개인정보는 문의 처리 완료 후 1년간 보관하며, 보유 기간이 지나면 지체 없이 파기합니다.

4. 정보 제공 및 처리 위탁

제공된 정보는 문의 응대 목적으로 사용합니다. 외부 서비스에 처리를 위탁하는 경우 해당 서비스와 처리 범위를 운영 방침에 명시합니다.

5. 동의 거부

개인정보 수집 및 이용에 동의하지 않을 수 있습니다. 다만, 동의하지 않을 경우 온라인 문의 서비스를 이용할 수 없습니다.

`, }, sitemap: { title: "사이트맵", body: ``, }, }; 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 = `

${content.title}

${content.body}
`; 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 = `

오시는 길

Google 지도에서 크게 보기
`; 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) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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 = ` ${rows.map((row) => ` `).join("")}
${escapeHtml(item.tableHeaders?.[0] || "사업명")} ${escapeHtml(item.tableHeaders?.[1] || "사업기간")}
${escapeHtml(row[0])} ${escapeHtml(row[1])}
`; } // 기술 역량: 모바일 특허증 본문과 오브젝트의 겹침 방지·등장/퇴장 처리. 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 = `${escapeHtml(labels[type])}`; const categoryDirection = `${direction < 0 ? "이전 분야" : "다음 분야"}`; 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 ? `` : ""; const preview = `${thumbnail(item.image, "thumbnail-background")}${escapeHtml(item.title)}`; for (const [container, className, html] of [ [progress, "dot-button project-link", ''], [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); })();