Files
tront.noname.company/script.js
zenn 7748eb2973 연혁을 개발 역량 중심으로 정리하고 내용을 HTML로 분리
- 연도별 제목을 프로젝트와 개발 역량 중심으로 변경
- 모두의약국, BISPET, BEMS 등 서비스의 대상과 기능을 설명해 이해도 개선
- 반복되는 분류명, 강조 문구와 기술 태그를 제거하고 제목과 설명으로 간소화
- 설명 안의 핵심 기술과 서비스명을 강조하고 별도 연혁 스타일 추가
- 연혁 내용을 두 HTML 페이지로 옮기고 공통 스크립트는 탭 전환과 키보드 이동만 담당
- 변경된 스크립트와 연혁 스타일에 버전 URL을 적용하고 README에 편집 방법 기록

검증: JavaScript 문법 및 변경 공백 검사 통과
2026-10-07 11:35:04 +09:00

362 lines
16 KiB
JavaScript

/* 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; }
})();