- 연도별 제목을 프로젝트와 개발 역량 중심으로 변경 - 모두의약국, BISPET, BEMS 등 서비스의 대상과 기능을 설명해 이해도 개선 - 반복되는 분류명, 강조 문구와 기술 태그를 제거하고 제목과 설명으로 간소화 - 설명 안의 핵심 기술과 서비스명을 강조하고 별도 연혁 스타일 추가 - 연혁 내용을 두 HTML 페이지로 옮기고 공통 스크립트는 탭 전환과 키보드 이동만 담당 - 변경된 스크립트와 연혁 스타일에 버전 URL을 적용하고 README에 편집 방법 기록 검증: JavaScript 문법 및 변경 공백 검사 통과
362 lines
16 KiB
JavaScript
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; }
|
|
})();
|