Files
tront.noname.company/script.js

411 lines
20 KiB
JavaScript

/* TRONT static website. Contact details below are intentionally temporary. */
(() => {
'use strict';
const CONTACT = Object.freeze({
email: 'hello@tront.example', // Replace with the company's actual email.
phone: '053-000-0000', // Replace with the company's actual phone.
temporary: true // Set false after replacing both details.
});
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();
$$('[data-contact-email]').forEach(link => {
link.href = `mailto:${CONTACT.email}`;
if (link.closest('.contact-row')) link.textContent = CONTACT.email;
});
$$('[data-contact-phone]').forEach(link => {
link.href = `tel:${CONTACT.phone.replace(/[^+\d]/g, '')}`;
link.textContent = CONTACT.phone;
});
$('.contact-placeholder').hidden = !CONTACT.temporary;
// 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();
}));
const history = {
2008: { label: 'THE BEGINNING', title: '기술로 여는 첫 번째 길', description: '2008년 12월 1일, 주식회사 트론트의 여정이 시작되었습니다. DDNS 서버, 그룹웨어, LMS 원격강의 지원 시스템과 차량용 미들웨어 개발로 기반을 다졌습니다.', tags: ['DDNS Server', 'Groupware', 'LMS'] },
2012: { label: 'NEW CONNECTIONS', title: '현장과 데이터를 연결하다', description: '디지털 사이니지와 NFC 자산 관리, 청라 U-CITY 환경 서비스 등으로 기술의 영역을 확장했습니다. 이후 RFID 기반 IT 자산 관리 솔루션으로 연결의 범위를 넓혔습니다.', tags: ['Digital Signage', 'NFC / RFID', 'U-CITY'] },
2018: { label: 'MOVING FORWARD', title: '세계의 도로 위로, 기술의 확장', description: '현대오토에버 국내외 내비게이션 HMI 개발을 시작했습니다. C++와 Qt 기반 GUI 및 인터페이스 기술로 다양한 시장의 차량용 사용자 경험 구현에 참여했습니다.', tags: ['Hyundai AutoEver', 'C++ / Qt', 'Navigation HMI'] },
2022: { label: 'EVERYDAY IMPACT', title: '기술이 일상에 가까워지는 순간', description: '약사 커뮤니티 모두의약국 개발을 시작으로 전문 플랫폼 역량을 확장했습니다. 빌딩 에너지 통합 관제 BEMS와 반려동물 커뮤니티 BISPET 등 산업과 일상을 위한 프로젝트를 이어갔습니다.', tags: ['모두의약국', 'BEMS', 'BISPET'] },
2025: { label: "WHAT'S NEXT", title: '더 넓은 연결, 다음의 가능성', description: '블록체인 기반 기부 플랫폼 GivOn 관리자 개발과 소규모 기관차 관제 프로그램 개발로 기술의 쓰임을 넓혀갑니다.', tags: ['Web3', '관제 시스템', '디지털 플랫폼'] }
};
const yearTabs = $$('.timeline-tabs [role="tab"]');
function selectYear(button, focus = false) {
const year = button.dataset.year;
const data = history[year];
yearTabs.forEach(tab => {
tab.setAttribute('aria-selected', String(tab === button));
tab.tabIndex = tab === button ? 0 : -1;
});
$('#timeline-panel').setAttribute('aria-labelledby', button.id);
$('.timeline-year').textContent = year;
$('.timeline-detail .eyebrow').textContent = data.label;
$('.timeline-detail h3').textContent = data.title;
$('.timeline-description').textContent = data.description;
$('.timeline-detail .tags').replaceChildren(...data.tags.map(tag => {
const span = document.createElement('span'); span.textContent = tag; return span;
}));
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); }
});
});
let toastTimer;
function notify(message) {
const toast = $('.toast');
toast.textContent = message;
toast.classList.add('is-visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('is-visible'), 3200);
}
$('.copy-email').addEventListener('click', async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(CONTACT.email);
notify('이메일 주소를 복사했습니다.');
} catch {
const range = document.createRange();
range.selectNodeContents($('.contact-row [data-contact-email]'));
const selection = window.getSelection();
selection.removeAllRanges(); selection.addRange(range);
notify('이메일 주소를 선택했습니다. 복사 단축키를 사용해 주세요.');
}
});
// 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; }
})();