/* Shared interactions for the TRONT website. */ (() => { 'use strict'; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; document.documentElement.classList.add('js-ready'); $('#copyright-year').textContent = new Date().getFullYear(); // Mobile navigation is available without changing desktop navigation. const menuButton = $('.menu-toggle'); const mobileMenu = $('#mobile-menu'); function closeMenu(returnFocus = false) { menuButton.setAttribute('aria-expanded', 'false'); menuButton.setAttribute('aria-label', '메뉴 열기'); mobileMenu.hidden = true; if (returnFocus) menuButton.focus(); } menuButton.addEventListener('click', () => { const open = menuButton.getAttribute('aria-expanded') !== 'true'; menuButton.setAttribute('aria-expanded', String(open)); menuButton.setAttribute('aria-label', open ? '메뉴 닫기' : '메뉴 열기'); mobileMenu.hidden = !open; }); $$('a', mobileMenu).forEach(link => link.addEventListener('click', () => closeMenu())); document.addEventListener('keydown', event => { if (event.key === 'Escape' && !mobileMenu.hidden) closeMenu(true); }); document.addEventListener('click', event => { if (!mobileMenu.hidden && !event.target.closest('.site-header')) closeMenu(); }); window.matchMedia('(min-width: 851px)').addEventListener('change', event => { if (event.matches) closeMenu(); }); // Scroll effects are scheduled once per animation frame. const header = $('.site-header'); const progress = $('.scroll-progress'); const navLinks = $$('.desktop-nav a'); const navSections = navLinks.map(link => $(link.getAttribute('href'))); let scrollPending = false; function updateScroll() { const top = window.scrollY; const length = document.documentElement.scrollHeight - window.innerHeight; progress.style.transform = `scaleX(${length > 0 ? top / length : 0})`; header.classList.toggle('is-scrolled', top > 15); let active = -1; navSections.forEach((section, index) => { if (section.getBoundingClientRect().top <= 160) active = index; }); if ($('#contact').getBoundingClientRect().top <= 160) active = -1; navLinks.forEach((link, index) => { link.classList.toggle('is-active', index === active); if (index === active) link.setAttribute('aria-current', 'location'); else link.removeAttribute('aria-current'); }); scrollPending = false; } window.addEventListener('scroll', () => { if (!scrollPending) { scrollPending = true; requestAnimationFrame(updateScroll); } }, { passive: true }); updateScroll(); function countUp(element) { const target = Number(element.dataset.count); if (target === 2008 || reduceMotion.matches) return; const start = performance.now(); function step(now) { const amount = Math.min((now - start) / 1100, 1); element.textContent = Math.round(target * (1 - Math.pow(1 - amount, 3))); if (amount < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } if ('IntersectionObserver' in window) { const revealObserver = new IntersectionObserver(entries => { entries.forEach(entry => { if (!entry.isIntersecting) return; entry.target.classList.add('is-visible'); $$('[data-count]', entry.target).forEach(countUp); revealObserver.unobserve(entry.target); }); }, { threshold: 0.08, rootMargin: '0px 0px -20px 0px' }); $$('.reveal').forEach(element => revealObserver.observe(element)); } else { $$('.reveal').forEach(element => element.classList.add('is-visible')); } // Keep native details semantics; coordinate height, content and artwork motion. const services = $$('.service'); const serviceAnimations = new Map(); const expertiseArt = $('.expertise-art'); const capabilityModes = [ ['mobility', 'HMI', 'SMART MOBILITY'], ['monitoring', 'IoT', 'INDUSTRIAL MONITORING'], ['web3', 'WEB3', 'CONNECTED ASSETS'], ['platform', 'APP', 'COMMUNITY & SAAS'], ['interface', 'UX', 'DIGITAL INTERFACE'] ]; let artworkTextAnimation; function selectCapability(index) { const mode = capabilityModes[index]; expertiseArt.dataset.mode = mode ? mode[0] : 'idle'; $('.capability-index').textContent = mode ? `0${index + 1} / 05` : '— / 05'; $('.capability-name').textContent = mode ? mode[2] : 'EXPLORE OUR EXPERTISE'; $('.sculpture-core').textContent = mode ? mode[1] : '+'; $$('.capability-dots i').forEach((dot, i) => dot.classList.toggle('is-active', i === index)); artworkTextAnimation?.cancel(); if (!reduceMotion.matches) { artworkTextAnimation = $('.sculpture-core').animate( [{ opacity: 0, filter: 'blur(6px)', transform: 'translateY(8px)' }, { opacity: 1, filter: 'blur(0)', transform: 'translateY(0)' }], { duration: 480, easing: 'cubic-bezier(.22,1,.36,1)' } ); } } function settleService(service) { const current = serviceAnimations.get(service); if (current) { current.height.onfinish = null; current.height.cancel(); current.content.forEach(animation => animation.cancel()); serviceAnimations.delete(service); } service.open = service.classList.contains('is-expanded'); service.style.removeProperty('height'); service.style.removeProperty('overflow'); } function expandService(service, expanded) { if (service.classList.contains('is-expanded') === expanded) return; const startHeight = service.getBoundingClientRect().height; settleService(service); const summary = $('summary', service); const content = $('.service-content', service); service.classList.toggle('is-expanded', expanded); summary.setAttribute('aria-expanded', String(expanded)); content.inert = !expanded; service.open = true; const border = parseFloat(getComputedStyle(service).borderTopWidth) + parseFloat(getComputedStyle(service).borderBottomWidth); const endHeight = expanded ? service.getBoundingClientRect().height : summary.getBoundingClientRect().height + border; if (reduceMotion.matches || !service.animate) { service.open = expanded; return; } service.style.height = `${startHeight}px`; service.style.overflow = 'hidden'; const heightAnimation = service.animate( [{ height: `${startHeight}px` }, { height: `${endHeight}px` }], { duration: 580, easing: 'cubic-bezier(.22,1,.36,1)' } ); const contentAnimations = [$('.service-content > p', service), ...$$('.tags > span', service)].map((element, index) => element.animate( expanded ? [{ opacity: 0, transform: 'translateY(14px)' }, { opacity: 1, transform: 'translateY(0)' }] : [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(-7px)' }], { duration: expanded ? 350 : 160, delay: expanded ? 80 + index * 45 : 0, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'both' } )); serviceAnimations.set(service, { height: heightAnimation, content: contentAnimations }); heightAnimation.onfinish = () => settleService(service); } services.forEach((service, index) => { const summary = $('summary', service); const content = $('.service-content', service); service.classList.toggle('is-expanded', service.open); summary.setAttribute('aria-expanded', String(service.open)); content.id = `service-content-${index + 1}`; summary.setAttribute('aria-controls', content.id); content.inert = !service.open; summary.addEventListener('click', event => { event.preventDefault(); const expanded = !service.classList.contains('is-expanded'); services.forEach(other => expandService(other, other === service && expanded)); selectCapability(expanded ? index : -1); }); }); window.addEventListener('resize', () => services.forEach(settleService), { passive: true }); reduceMotion.addEventListener('change', () => { services.forEach(settleService); artworkTextAnimation?.cancel(); }); const cards = $$('.project-card'); const filters = $$('.filter'); filters.forEach(button => button.addEventListener('click', () => { filters.forEach(filter => { const selected = filter === button; filter.classList.toggle('is-active', selected); filter.setAttribute('aria-pressed', String(selected)); }); let visible = 0; cards.forEach(card => { const show = button.dataset.filter === 'all' || button.dataset.filter === card.dataset.category; card.hidden = !show; if (show) { card.classList.add('is-visible'); visible++; } }); $('.project-count').textContent = `${visible} PROJECT${visible === 1 ? '' : 'S'}`; updateScroll(); })); // Timeline content lives in HTML; JavaScript only switches accessible panels. const yearTabs = $$('.timeline-tabs [role="tab"]'); function selectYear(button, focus = false) { yearTabs.forEach(tab => { const selected = tab === button; tab.setAttribute('aria-selected', String(selected)); tab.tabIndex = selected ? 0 : -1; const panel = document.getElementById(tab.getAttribute('aria-controls')); if (panel) panel.hidden = !selected; }); if (focus) button.focus(); } yearTabs.forEach((button, index) => { button.addEventListener('click', () => selectYear(button)); button.addEventListener('keydown', event => { let target; if (event.key === 'ArrowRight') target = (index + 1) % yearTabs.length; if (event.key === 'ArrowLeft') target = (index + yearTabs.length - 1) % yearTabs.length; if (event.key === 'Home') target = 0; if (event.key === 'End') target = yearTabs.length - 1; if (target !== undefined) { event.preventDefault(); selectYear(yearTabs[target], true); } }); }); // Original Canvas artwork: an evolving, connected torus. No 3D library. const canvas = $('#connection-canvas'); const ctx = canvas.getContext('2d'); const motionButton = $('.motion-toggle'); let motionPaused = reduceMotion.matches; let artVisible = true; let frame = 0; let previousTime = 0; let rotation = 0; let width = 0, height = 0; let pointerX = 0, pointerY = 0, tiltX = 0, tiltY = 0; let ringSteps = 100, tubeSteps = 36; let basePoints = []; function updateMotionState() { document.body.classList.toggle('motion-paused', motionPaused); motionButton.setAttribute('aria-pressed', String(motionPaused)); motionButton.setAttribute('aria-label', motionPaused ? '그래픽 애니메이션 재생' : '그래픽 애니메이션 일시정지'); motionButton.firstElementChild.textContent = motionPaused ? '▷' : 'Ⅱ'; cancelAnimationFrame(frame); if (!motionPaused && artVisible && !document.hidden) { previousTime = 0; frame = requestAnimationFrame(animate); } else if (ctx) draw(); } motionButton.addEventListener('click', () => { motionPaused = !motionPaused; updateMotionState(); }); reduceMotion.addEventListener('change', event => { motionPaused = event.matches; updateMotionState(); }); function buildPoints() { basePoints = []; ringSteps = width < 450 ? 76 : 100; tubeSteps = width < 450 ? 30 : 36; for (let u = 0; u < ringSteps; u++) { const a = u / ringSteps * Math.PI * 2; const radius = 1.04 + .085 * Math.cos(a * 3); for (let v = 0; v < tubeSteps; v++) { const b = v / tubeSteps * Math.PI * 2; const twist = b + a * 2; const tube = .385 + .025 * Math.sin(a * 3); basePoints.push({ x: (radius + tube * Math.cos(twist)) * Math.cos(a), y: (radius + tube * Math.cos(twist)) * Math.sin(a), z: tube * Math.sin(twist) + .10 * Math.sin(a * 3), nx: Math.cos(twist) * Math.cos(a), ny: Math.cos(twist) * Math.sin(a), nz: Math.sin(twist) }); } } } function rotate(x, y, z, rx, ry, rz) { const y1 = y * Math.cos(rx) - z * Math.sin(rx); const z1 = y * Math.sin(rx) + z * Math.cos(rx); const x2 = x * Math.cos(ry) + z1 * Math.sin(ry); const z2 = -x * Math.sin(ry) + z1 * Math.cos(ry); return [x2 * Math.cos(rz) - y1 * Math.sin(rz), x2 * Math.sin(rz) + y1 * Math.cos(rz), z2]; } function draw() { if (!ctx || !width || !height) return; ctx.clearRect(0, 0, width, height); const scale = Math.min(width * .29, height * .30); const centerX = width * .5, centerY = height * .5; const rx = .64 + tiltY * .15, ry = -.32 + tiltX * .15, rz = -.56 + rotation * .07; const points = basePoints.map(point => { const [x, y, z] = rotate(point.x, point.y, point.z, rx, ry, rz); const normal = rotate(point.nx, point.ny, point.nz, rx, ry, rz); const perspective = 4.8 / (4.8 - z); return { x: centerX + x * scale * perspective, y: centerY + y * scale * perspective, z, light: Math.max(0, normal[0] * -.35 + normal[1] * -.55 + normal[2] * .76) }; }); const faces = []; for (let u = 0; u < ringSteps; u++) { for (let v = 0; v < tubeSteps; v++) { const a = points[u * tubeSteps + v]; const b = points[((u + 1) % ringSteps) * tubeSteps + v]; const c = points[((u + 1) % ringSteps) * tubeSteps + (v + 1) % tubeSteps]; const d = points[u * tubeSteps + (v + 1) % tubeSteps]; faces.push({ a, b, c, d, z: (a.z + b.z + c.z + d.z) / 4, light: (a.light + b.light + c.light + d.light) / 4, stripe: v % 3 === 0 }); } } faces.sort((a, b) => a.z - b.z); faces.forEach(face => { const light = 27 + face.light * 39; ctx.beginPath(); ctx.moveTo(face.a.x, face.a.y);ctx.lineTo(face.b.x, face.b.y);ctx.lineTo(face.c.x, face.c.y);ctx.lineTo(face.d.x, face.d.y);ctx.closePath(); ctx.fillStyle = `hsl(${225 + face.light * 4} 88% ${light}%)`; ctx.fill(); ctx.lineWidth = face.stripe ? .65 : .35; ctx.strokeStyle = `hsl(228 91% ${Math.min(81, light + (face.stripe ? 20 : 6))}% / ${face.stripe ? .62 : .35})`; ctx.stroke(); }); // Fine orbital path and lime satellite contrast with the blue surface. ctx.save();ctx.translate(centerX, centerY);ctx.rotate(-.54); ctx.beginPath();ctx.ellipse(0, 0, scale * 1.67, scale * .72, 0, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(123, 145, 107, .25)';ctx.lineWidth = .7;ctx.stroke(); const orbit = 2.77 + rotation * .11; const sx = Math.cos(orbit) * scale * 1.67, sy = Math.sin(orbit) * scale * .72; const radius = Math.max(9, scale * .085); const gradient = ctx.createRadialGradient(sx - radius * .35, sy - radius * .4, 0, sx, sy, radius); gradient.addColorStop(0, '#edf7b6');gradient.addColorStop(.45, '#d5ed7e');gradient.addColorStop(1, '#8da742'); ctx.beginPath();ctx.arc(sx, sy, radius, 0, Math.PI * 2);ctx.fillStyle = gradient;ctx.fill();ctx.restore(); } function resizeArt() { const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height; const ratio = Math.min(window.devicePixelRatio || 1, 1.75); canvas.width = Math.round(width * ratio);canvas.height = Math.round(height * ratio); if (!ctx) return; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); buildPoints();draw(); } function animate(now) { if (motionPaused || !artVisible || document.hidden) return; if (now - previousTime >= 40) { const elapsed = previousTime ? Math.min(now - previousTime, 80) : 40; rotation += elapsed * .00025; tiltX += (pointerX - tiltX) * .055;tiltY += (pointerY - tiltY) * .055; draw();previousTime = now; } frame = requestAnimationFrame(animate); } if (ctx) { resizeArt(); if ('ResizeObserver' in window) new ResizeObserver(resizeArt).observe(canvas); else window.addEventListener('resize', resizeArt); $('.hero-art').addEventListener('pointermove', event => { if (event.pointerType === 'touch' || motionPaused) return; const rect = canvas.getBoundingClientRect(); pointerX = (event.clientX - rect.left) / rect.width - .5; pointerY = (event.clientY - rect.top) / rect.height - .5; }); $('.hero-art').addEventListener('pointerleave', () => { pointerX = 0; pointerY = 0; }); if ('IntersectionObserver' in window) new IntersectionObserver(entries => { artVisible = entries[0].isIntersecting;updateMotionState(); }).observe(canvas); document.addEventListener('visibilitychange', updateMotionState); updateMotionState(); } else { motionButton.hidden = true; } })();