const caseData = { election: { kicker: "2.1 Election Broadcast", title: "2026 지방선거 방송 그래픽 / 데이터 송출 솔루션", client: "Yonhapnews TV / Local Stations", field: "Tornado3 / Karisma", status: "ELECT 2026", description: "6·3 지방선거 개표방송 화면 흐름을 기반으로, 지역민방별 선거구와 데이터 범위에 맞춘 실시간 그래픽 송출 시스템을 구성했습니다.", tags: ["Tornado3", "Karisma", "SBS API", "Regional CG"], statValue: "89 Cuts", statLabel: "지역민방용 Normal, Top, Bottom 그래픽 컷 구성", bullets: [ "KNN, UBC, TBC, KBC, G1, TJB, JTV 지역 필터 기반 운영", "광역단체장, 교육감, 기초단체장 개표 데이터와 사전 투표율 화면 구성", "채널별 스케줄 큐, 반복 컷, 다음/대기/송출 상태를 분리한 운영 UI 개발" ], images: [ { src: "assets/media/local-election-2026-premium.webp", alt: "2026 지방선거 지역민방 방송 그래픽 운영 쇼케이스" }, { src: "assets/media/local-election-2026-race.webp", alt: "2026 지방선거 광역단체장 1위 후보 그래픽" }, { src: "assets/media/local-election-2026-landscape.webp", alt: "2026 지방선거 기초단체장 판세 그래픽" } ] }, virtual: { kicker: "2.2 Virtual Graphic", title: "버추얼 그래픽 솔루션", client: "Broadcast Studio", field: "VR / AR System", status: "3D READY", description: "Unreal Engine과 Unity를 활용해 VR, AR 기반의 실시간 3D 그래픽 시스템을 개발합니다.", tags: ["Unreal Engine", "Unity", "3D Data"], statValue: "VR / AR", statLabel: "실시간 데이터 기반 3D 그래픽 처리", bullets: [ "Unreal Engine 기반 VR/AR 시스템 개발", "실시간 데이터 3D 그래픽 처리 시스템 적용", "Unity 활용 영상 콘텐츠 제작" ], images: [ { src: "assets/media/virtual-election-studio.webp", alt: "버추얼 선거방송 스튜디오 그래픽" }, { src: "assets/media/virtual-candidate-wall.webp", alt: "버추얼 후보자 데이터 월" }, { src: "assets/media/virtual-debate-wall.webp", alt: "버추얼 토론 방송 그래픽 월" } ] }, finance: { kicker: "2.3 Finance Broadcast", title: "증권 데이터 및 경제 방송 송출 그래픽 시스템", client: "Finance Media", field: "Database / API", status: "MARKET API", description: "코스콤 데이터와 해외 지수 데이터를 연계해 방송 송출 그래픽과 API 서버를 구현합니다.", tags: ["Koscom", "REST API", "DB Server"], statValue: "API", statLabel: "국내외 지수 데이터 연계 및 송출", bullets: [ "코스콤 데이터와 해외 지수 연계 데이터베이스 서버 구축", "실시간 그래픽 연계 데이터 송수신 프로그램 개발", "REST 서버 API 구현 및 방송 송출 시스템 적용" ], images: [ { src: "assets/media/finance-chart.webp", alt: "경제 방송 주가 차트 그래픽" }, { src: "assets/media/finance-index.webp", alt: "경제 방송 지수 그래픽" }, { src: "assets/media/finance-db-console.webp", alt: "경제 방송 데이터베이스 콘솔" } ] }, ai: { kicker: "2.4 AI Vision", title: "AI 기반 거리 측정 시스템", client: "KPGA", field: "AI Learning", status: "AI RESULT", description: "KPGA 공식 기록에 사용되는 그린 홀 컵 간 거리 측정 솔루션을 개발했습니다.", tags: ["CUDA", "Deep Learning", "Coordinate"], statValue: "KPGA", statLabel: "공식 기록 환경에 적용된 거리 측정 솔루션", bullets: [ "CUDA 기반 딥러닝 모델 학습", "카메라 영상 신호 수신 후 공 위치와 좌표 변환", "골프 중계와 기록 환경에 맞춘 AI 거리 측정" ], images: [ { src: "assets/media/ai-distance-result.webp", alt: "AI 기반 거리 측정 결과 화면" }, { src: "assets/media/ai-training-green.webp", alt: "골프 그린 AI 학습 화면" }, { src: "assets/media/ai-detection-console.webp", alt: "AI 영상 분석 콘솔 화면" } ] }, shopping: { kicker: "2.5 Commerce Broadcast", title: "홈쇼핑 자동화 송출 시스템 / 라이브 톡", client: "Commerce Channel", field: "Automation", status: "CONTROL", description: "송출 스케줄과 원격 제어, 모바일 연동을 포함한 홈쇼핑 방송 운영 시스템을 개발합니다.", tags: ["Remote Control", "Mobile", "Schedule"], statValue: "Automation", statLabel: "송출 스케줄과 모바일 운영 흐름 연동", bullets: [ "송출 스케줄 관리 데이터 송수신 모듈 개발", "홈쇼핑 송출 프로그램 원격제어 로컬/모바일 연동", "웹 페이지와 앱을 활용한 실시간 방송 데이터 송출" ], images: [ { src: "assets/media/shopping-control-dashboard.webp", alt: "홈쇼핑 송출 제어 대시보드" }, { src: "assets/media/shopping-live-output.webp", alt: "홈쇼핑 라이브 방송 송출 화면" }, { src: "assets/media/mobile-live-talk.webp", alt: "홈쇼핑 모바일 라이브 톡 화면" } ] }, sports: { kicker: "2.6 Sports TV Graphic", title: "스포츠 / 게임 데이터 솔루션 송출 시스템", client: "Sports & Game", field: "TV Graphic", status: "UHD CG", description: "Live Data를 자막시스템에서 사용 가능한 형태로 변환하고 다양한 종목의 TVG 시스템을 제작합니다.", tags: ["HD/UHD", "International Signal", "Scorebug"], statValue: "IS", statLabel: "국제신호 제작과 고해상도 CG 송출 대응", bullets: [ "HD/UHD급 CG 제작과 국제신호 제작", "3D/2D Cut, 애니메이션 등 특수 효과 적용", "구기, 투기, 계측 종목 등 다양한 스포츠 TVG 구성" ], images: [ { src: "assets/media/sports-game-result.webp", alt: "게임 결과 방송 그래픽" }, { src: "assets/media/sports-player-profile.webp", alt: "선수 프로필 방송 그래픽" }, { src: "assets/media/football-scorebug.webp", alt: "축구 스코어버그 방송 그래픽" } ] } }; const body = document.body; const header = document.getElementById("siteHeader"); const mobilePanel = document.getElementById("mobilePanel"); const menuToggle = document.querySelector(".menu-toggle"); const cursor = document.querySelector(".cursor"); const loaderBar = document.getElementById("loaderBar"); const loaderValue = document.getElementById("loaderValue"); const caseStage = document.querySelector(".case-stage"); const caseImage = document.getElementById("caseImage"); const caseKicker = document.getElementById("caseKicker"); const caseName = document.getElementById("caseName"); const caseDescription = document.getElementById("caseDescription"); const caseBullets = document.getElementById("caseBullets"); const caseThumbs = document.getElementById("caseThumbs"); const caseClient = document.getElementById("caseClient"); const caseField = document.getElementById("caseField"); const caseStatus = document.getElementById("caseStatus"); const caseTags = document.getElementById("caseTags"); const caseStatValue = document.getElementById("caseStatValue"); const caseStatLabel = document.getElementById("caseStatLabel"); const navLinks = [...document.querySelectorAll(".primary-nav a")]; let loaderProgress = 0; const finishLoading = () => { body.classList.remove("is-loading"); body.classList.add("is-ready"); }; if (loaderBar && loaderValue) { const loaderTimer = window.setInterval(() => { loaderProgress = Math.min(loaderProgress + Math.ceil(Math.random() * 12), 100); loaderBar.style.width = `${loaderProgress}%`; loaderValue.textContent = String(loaderProgress).padStart(2, "0"); if (loaderProgress >= 100) { window.clearInterval(loaderTimer); window.setTimeout(finishLoading, 220); } }, 55); } else { finishLoading(); } const updateScrollState = () => { if (!header) return; const scrollTop = window.scrollY || document.documentElement.scrollTop; const scrollable = document.documentElement.scrollHeight - window.innerHeight; const progress = scrollable > 0 ? (scrollTop / scrollable) * 100 : 0; header.classList.toggle("is-scrolled", scrollTop > 20); header.style.setProperty("--page-progress", `${progress}%`); let currentHref = navLinks[0]?.getAttribute("href"); navLinks.forEach(link => { const target = document.querySelector(link.getAttribute("href")); if (target && target.getBoundingClientRect().top <= window.innerHeight * 0.45) { currentHref = link.getAttribute("href"); } }); navLinks.forEach(link => { link.setAttribute("aria-current", link.getAttribute("href") === currentHref ? "true" : "false"); }); }; window.addEventListener("scroll", updateScrollState, { passive: true }); window.addEventListener("resize", updateScrollState); updateScrollState(); const revealObserver = new IntersectionObserver( entries => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add("in-view"); revealObserver.unobserve(entry.target); } }); }, { threshold: 0.16 } ); document.querySelectorAll(".reveal").forEach(element => revealObserver.observe(element)); const setMenuOpen = open => { if (!menuToggle || !mobilePanel) return; menuToggle.setAttribute("aria-expanded", String(open)); menuToggle.setAttribute("aria-label", open ? "메뉴 닫기" : "메뉴 열기"); mobilePanel.classList.toggle("is-open", open); mobilePanel.setAttribute("aria-hidden", String(!open)); menuToggle.innerHTML = ``; if (window.lucide) window.lucide.createIcons(); }; if (menuToggle) { menuToggle.addEventListener("click", () => { const open = menuToggle.getAttribute("aria-expanded") !== "true"; setMenuOpen(open); }); } mobilePanel?.querySelectorAll("a").forEach(link => { link.addEventListener("click", () => setMenuOpen(false)); }); const fillList = (element, items, tagName = "span") => { if (!element) return; const nodes = items.map(item => { const node = document.createElement(tagName); node.textContent = item; return node; }); element.replaceChildren(...nodes); }; const renderCase = (key, imageIndex = 0) => { const data = caseData[key]; if (!data || !caseImage) return; const image = data.images[imageIndex] || data.images[0]; caseStage?.classList.add("is-switching"); window.setTimeout(() => caseStage?.classList.remove("is-switching"), 180); caseImage.src = image.src; caseImage.alt = image.alt; if (caseKicker) caseKicker.textContent = data.kicker; if (caseName) caseName.textContent = data.title; if (caseDescription) caseDescription.textContent = data.description; if (caseClient) caseClient.textContent = data.client; if (caseField) caseField.textContent = data.field; if (caseStatus) caseStatus.textContent = data.status; if (caseStatValue) caseStatValue.textContent = data.statValue; if (caseStatLabel) caseStatLabel.textContent = data.statLabel; fillList(caseTags, data.tags); fillList(caseBullets, data.bullets, "li"); if (caseThumbs) { const buttons = data.images.map((item, index) => { const button = document.createElement("button"); button.type = "button"; button.className = index === imageIndex ? "is-active" : ""; button.dataset.caseThumb = String(index); button.setAttribute("aria-label", item.alt); const thumb = document.createElement("img"); thumb.src = item.src; thumb.alt = ""; button.appendChild(thumb); button.addEventListener("click", () => renderCase(key, index)); return button; }); caseThumbs.replaceChildren(...buttons); } }; document.querySelectorAll(".case-tabs button").forEach(button => { button.addEventListener("click", () => { document.querySelectorAll(".case-tabs button").forEach(item => { item.classList.remove("is-active"); item.setAttribute("aria-selected", "false"); }); button.classList.add("is-active"); button.setAttribute("aria-selected", "true"); renderCase(button.dataset.case); }); }); renderCase("election"); if (window.matchMedia("(pointer: fine)").matches && cursor) { window.addEventListener("pointermove", event => { cursor.style.transform = `translate3d(${event.clientX}px, ${event.clientY}px, 0) translate(-50%, -50%)`; cursor.classList.add("is-visible"); }); document.querySelectorAll("a, button").forEach(element => { element.addEventListener("pointerenter", () => cursor.classList.add("is-active")); element.addEventListener("pointerleave", () => cursor.classList.remove("is-active")); }); } window.addEventListener("keydown", event => { if (event.key === "Escape") setMenuOpen(false); }); if (window.lucide) { window.lucide.createIcons(); }