735 lines
26 KiB
JavaScript
735 lines
26 KiB
JavaScript
/**
|
||
* REBOURS — Main Script
|
||
* CAD/CAO-inspired interface · GSAP ScrollTrigger · Technical drawing overlays · Ambient sound
|
||
*/
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
|
||
// ---- CONFIG ----
|
||
const isMobile = window.innerWidth <= 600;
|
||
const isTouch = 'ontouchstart' in window;
|
||
|
||
gsap.registerPlugin(ScrollTrigger);
|
||
|
||
// ---- HEADER HEIGHT → CSS VAR ----
|
||
const setHeaderHeight = () => {
|
||
const h = document.querySelector('.header')?.offsetHeight || 44;
|
||
document.documentElement.style.setProperty('--header-h', h + 'px');
|
||
};
|
||
setHeaderHeight();
|
||
window.addEventListener('resize', setHeaderHeight);
|
||
|
||
// ==========================================================
|
||
// 1. CAD CROSSHAIR CURSOR WITH X/Y COORDINATES
|
||
// ==========================================================
|
||
|
||
let attachCursorHover = () => {};
|
||
|
||
if (!isMobile && !isTouch) {
|
||
const cursorH = document.createElement('div');
|
||
cursorH.className = 'cad-h';
|
||
const cursorV = document.createElement('div');
|
||
cursorV.className = 'cad-v';
|
||
const cursorCenter = document.createElement('div');
|
||
cursorCenter.className = 'cad-center';
|
||
const cursorCoords = document.createElement('div');
|
||
cursorCoords.className = 'cad-coords';
|
||
|
||
[cursorH, cursorV, cursorCenter, cursorCoords].forEach(el => document.body.appendChild(el));
|
||
|
||
let visible = false;
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
const x = e.clientX;
|
||
const y = e.clientY;
|
||
|
||
cursorH.style.left = (x - 20) + 'px';
|
||
cursorH.style.top = y + 'px';
|
||
cursorV.style.left = x + 'px';
|
||
cursorV.style.top = (y - 20) + 'px';
|
||
cursorCenter.style.left = x + 'px';
|
||
cursorCenter.style.top = y + 'px';
|
||
cursorCoords.style.left = (x + 16) + 'px';
|
||
cursorCoords.style.top = (y + 14) + 'px';
|
||
|
||
cursorCoords.textContent =
|
||
'X:' + String(Math.round(x)).padStart(4, '0') +
|
||
' Y:' + String(Math.round(y)).padStart(4, '0');
|
||
|
||
if (!visible) {
|
||
visible = true;
|
||
[cursorH, cursorV, cursorCenter, cursorCoords].forEach(el => {
|
||
el.style.opacity = '1';
|
||
});
|
||
}
|
||
});
|
||
|
||
attachCursorHover = (elements) => {
|
||
elements.forEach(el => {
|
||
el.addEventListener('mouseenter', () => {
|
||
cursorH.classList.add('cad-hover');
|
||
cursorV.classList.add('cad-hover');
|
||
cursorCenter.classList.add('cad-hover');
|
||
});
|
||
el.addEventListener('mouseleave', () => {
|
||
cursorH.classList.remove('cad-hover');
|
||
cursorV.classList.remove('cad-hover');
|
||
cursorCenter.classList.remove('cad-hover');
|
||
});
|
||
});
|
||
};
|
||
|
||
attachCursorHover(document.querySelectorAll(
|
||
'a, button, input, .product-card, summary, .panel-close'
|
||
));
|
||
}
|
||
|
||
// ==========================================================
|
||
// 2. INTERACTIVE GRID
|
||
// ==========================================================
|
||
|
||
const gridContainer = document.getElementById('interactive-grid');
|
||
const COLORS = [
|
||
'rgba(232,168,0,0.45)',
|
||
'rgba(232,168,0,0.32)',
|
||
'rgba(232,168,0,0.18)',
|
||
];
|
||
|
||
function buildGrid() {
|
||
if (!gridContainer) return;
|
||
gridContainer.innerHTML = '';
|
||
const CELL = isMobile ? 38 : 60;
|
||
const cols = Math.ceil(window.innerWidth / CELL);
|
||
const rows = Math.ceil(window.innerHeight / CELL);
|
||
gridContainer.style.display = 'grid';
|
||
gridContainer.style.gridTemplateColumns = `repeat(${cols}, ${CELL}px)`;
|
||
gridContainer.style.gridTemplateRows = `repeat(${rows}, ${CELL}px)`;
|
||
|
||
for (let i = 0; i < cols * rows; i++) {
|
||
const cell = document.createElement('div');
|
||
cell.className = 'grid-cell';
|
||
cell.addEventListener('mouseenter', () => {
|
||
cell.style.transition = 'none';
|
||
cell.style.backgroundColor = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||
});
|
||
cell.addEventListener('mouseleave', () => {
|
||
cell.style.transition = 'background-color 1.4s ease-out';
|
||
cell.style.backgroundColor = 'transparent';
|
||
});
|
||
gridContainer.appendChild(cell);
|
||
}
|
||
}
|
||
|
||
buildGrid();
|
||
let rt;
|
||
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(buildGrid, 150); });
|
||
|
||
// ==========================================================
|
||
// 3. GSAP SCROLL ANIMATIONS — CAD REVEAL
|
||
// ==========================================================
|
||
|
||
// ---- Hero parallax ----
|
||
const heroImg = document.querySelector('.hero-img');
|
||
if (heroImg) {
|
||
gsap.to(heroImg, {
|
||
yPercent: 15,
|
||
ease: 'none',
|
||
scrollTrigger: {
|
||
trigger: '.hero',
|
||
start: 'top top',
|
||
end: 'bottom top',
|
||
scrub: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
// ---- Collection header: construction line draw-in ----
|
||
const collectionHeader = document.querySelector('.collection-header');
|
||
if (collectionHeader) {
|
||
// Add a construction line element
|
||
const line = document.createElement('div');
|
||
line.className = 'cad-construction-line';
|
||
collectionHeader.appendChild(line);
|
||
|
||
gsap.from(line, {
|
||
scaleX: 0,
|
||
transformOrigin: 'left center',
|
||
duration: 0.8,
|
||
ease: 'power2.out',
|
||
scrollTrigger: {
|
||
trigger: collectionHeader,
|
||
start: 'top 85%',
|
||
},
|
||
});
|
||
}
|
||
|
||
// ---- Product cards: staggered CAD reveal ----
|
||
const cards = document.querySelectorAll('.product-card');
|
||
|
||
cards.forEach((card, i) => {
|
||
const img = card.querySelector('.card-img-wrap img');
|
||
const meta = card.querySelector('.card-meta');
|
||
const imgWrap = card.querySelector('.card-img-wrap');
|
||
|
||
if (!img) return;
|
||
|
||
// Create a CAD scan line per card
|
||
const scanLine = document.createElement('div');
|
||
scanLine.className = 'card-scanline';
|
||
imgWrap.style.position = 'relative';
|
||
imgWrap.appendChild(scanLine);
|
||
|
||
// Create corner marks per card
|
||
const cornerOverlay = document.createElement('div');
|
||
cornerOverlay.className = 'card-corners';
|
||
cornerOverlay.innerHTML =
|
||
'<span class="cc cc-tl"></span>' +
|
||
'<span class="cc cc-tr"></span>' +
|
||
'<span class="cc cc-br"></span>' +
|
||
'<span class="cc cc-bl"></span>';
|
||
imgWrap.appendChild(cornerOverlay);
|
||
|
||
// Create coordinate annotation per card
|
||
const coordTag = document.createElement('div');
|
||
coordTag.className = 'card-coord-tag';
|
||
coordTag.textContent = `[${String(i + 1).padStart(3, '0')}] — ${img.naturalWidth || '1024'} × ${img.naturalHeight || '1024'} px`;
|
||
imgWrap.appendChild(coordTag);
|
||
|
||
// GSAP timeline triggered on scroll
|
||
const tl = gsap.timeline({
|
||
scrollTrigger: {
|
||
trigger: card,
|
||
start: 'top 88%',
|
||
toggleActions: 'play none none none',
|
||
},
|
||
});
|
||
|
||
// 1. Image clip-path reveal (bottom → top scan)
|
||
tl.fromTo(img,
|
||
{ clipPath: 'inset(100% 0 0 0)', filter: 'brightness(1.8) grayscale(100%)' },
|
||
{ clipPath: 'inset(0% 0 0 0)', filter: 'brightness(1) grayscale(15%)',
|
||
duration: 0.9, ease: 'power3.out' },
|
||
0
|
||
);
|
||
|
||
// 2. Scan line sweeps up
|
||
tl.fromTo(scanLine,
|
||
{ top: '100%', opacity: 1 },
|
||
{ top: '-2%', opacity: 0, duration: 0.9, ease: 'power3.out' },
|
||
0
|
||
);
|
||
|
||
// 3. Corner brackets appear
|
||
tl.fromTo(cornerOverlay.querySelectorAll('.cc'),
|
||
{ opacity: 0, scale: 0.5 },
|
||
{ opacity: 1, scale: 1, duration: 0.4, stagger: 0.06, ease: 'back.out(2)' },
|
||
0.3
|
||
);
|
||
|
||
// 4. Coordinate tag types in
|
||
tl.fromTo(coordTag,
|
||
{ opacity: 0, x: -10 },
|
||
{ opacity: 1, x: 0, duration: 0.4, ease: 'power2.out' },
|
||
0.5
|
||
);
|
||
|
||
// 5. Meta bar slides in
|
||
if (meta) {
|
||
tl.fromTo(meta,
|
||
{ opacity: 0, y: 8 },
|
||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out' },
|
||
0.4
|
||
);
|
||
}
|
||
});
|
||
|
||
// ---- Newsletter section: slide in ----
|
||
const nlLeft = document.querySelector('.nl-left');
|
||
const nlRight = document.querySelector('.nl-right');
|
||
if (nlLeft && nlRight) {
|
||
gsap.from(nlLeft, {
|
||
opacity: 0, x: -30, duration: 0.7, ease: 'power2.out',
|
||
scrollTrigger: { trigger: '.newsletter', start: 'top 80%' },
|
||
});
|
||
gsap.from(nlRight, {
|
||
opacity: 0, x: 30, duration: 0.7, ease: 'power2.out', delay: 0.15,
|
||
scrollTrigger: { trigger: '.newsletter', start: 'top 80%' },
|
||
});
|
||
}
|
||
|
||
// ==========================================================
|
||
// 4. TECHNICAL DRAWING OVERLAY (panel)
|
||
// ==========================================================
|
||
|
||
const panelImgCol = document.querySelector('.panel-img-col');
|
||
let techOverlay = null;
|
||
let techTimeline = null;
|
||
|
||
function createTechOverlay(card) {
|
||
if (techOverlay) techOverlay.remove();
|
||
|
||
techOverlay = document.createElement('div');
|
||
techOverlay.className = 'tech-overlay';
|
||
|
||
// Corner brackets
|
||
['tl', 'tr', 'br', 'bl'].forEach(pos => {
|
||
const corner = document.createElement('div');
|
||
corner.className = `tech-corner tech-corner--${pos}`;
|
||
techOverlay.appendChild(corner);
|
||
});
|
||
|
||
// Center crosshair
|
||
const centerH = document.createElement('div');
|
||
centerH.className = 'tech-center-h';
|
||
const centerV = document.createElement('div');
|
||
centerV.className = 'tech-center-v';
|
||
techOverlay.appendChild(centerH);
|
||
techOverlay.appendChild(centerV);
|
||
|
||
// Horizontal dimension line
|
||
const dimH = document.createElement('div');
|
||
dimH.className = 'tech-dim tech-dim--h';
|
||
dimH.innerHTML =
|
||
'<span class="tech-dim-arrow">◂</span>' +
|
||
'<span class="tech-dim-line"></span>' +
|
||
'<span class="tech-dim-text">840</span>' +
|
||
'<span class="tech-dim-line"></span>' +
|
||
'<span class="tech-dim-arrow">▸</span>';
|
||
techOverlay.appendChild(dimH);
|
||
|
||
// Vertical dimension line
|
||
const dimV = document.createElement('div');
|
||
dimV.className = 'tech-dim tech-dim--v';
|
||
dimV.innerHTML =
|
||
'<span class="tech-dim-arrow">▴</span>' +
|
||
'<span class="tech-dim-line"></span>' +
|
||
'<span class="tech-dim-text">420</span>' +
|
||
'<span class="tech-dim-line"></span>' +
|
||
'<span class="tech-dim-arrow">▾</span>';
|
||
techOverlay.appendChild(dimV);
|
||
|
||
// Reference text
|
||
const ref = document.createElement('div');
|
||
ref.className = 'tech-ref';
|
||
const idx = card ? card.dataset.index : '001';
|
||
ref.textContent = `REF: ${idx} — SCALE 1:5 — UNIT: mm`;
|
||
techOverlay.appendChild(ref);
|
||
|
||
// Blueprint grid
|
||
const grid = document.createElement('div');
|
||
grid.className = 'tech-grid';
|
||
techOverlay.appendChild(grid);
|
||
|
||
// Scan line
|
||
const scanline = document.createElement('div');
|
||
scanline.className = 'tech-scanline';
|
||
techOverlay.appendChild(scanline);
|
||
|
||
if (panelImgCol) panelImgCol.appendChild(techOverlay);
|
||
}
|
||
|
||
function animateTechOverlay() {
|
||
if (!techOverlay) return;
|
||
|
||
// Kill previous timeline
|
||
if (techTimeline) techTimeline.kill();
|
||
|
||
const corners = techOverlay.querySelectorAll('.tech-corner');
|
||
const centerH = techOverlay.querySelector('.tech-center-h');
|
||
const centerV = techOverlay.querySelector('.tech-center-v');
|
||
const dimH = techOverlay.querySelector('.tech-dim--h');
|
||
const dimV = techOverlay.querySelector('.tech-dim--v');
|
||
const ref = techOverlay.querySelector('.tech-ref');
|
||
const grid = techOverlay.querySelector('.tech-grid');
|
||
const scanline = techOverlay.querySelector('.tech-scanline');
|
||
|
||
techTimeline = gsap.timeline();
|
||
|
||
// 1. Blueprint grid fades in
|
||
techTimeline.fromTo(grid,
|
||
{ opacity: 0 },
|
||
{ opacity: 1, duration: 0.6, ease: 'power1.in' },
|
||
0
|
||
);
|
||
|
||
// 2. Scan line sweeps
|
||
techTimeline.fromTo(scanline,
|
||
{ top: '0%', opacity: 1 },
|
||
{ top: '100%', opacity: 0, duration: 1.1, ease: 'power2.inOut' },
|
||
0
|
||
);
|
||
|
||
// 3. Corner brackets draw in (scale from corner)
|
||
corners.forEach((c, i) => {
|
||
const origins = ['top left', 'top right', 'bottom right', 'bottom left'];
|
||
techTimeline.fromTo(c,
|
||
{ opacity: 0, scale: 0, borderColor: 'rgba(232,168,0,0)' },
|
||
{ opacity: 1, scale: 1, borderColor: 'rgba(232,168,0,0.6)',
|
||
transformOrigin: origins[i],
|
||
duration: 0.4, ease: 'back.out(1.5)' },
|
||
0.15 + i * 0.08
|
||
);
|
||
});
|
||
|
||
// 4. Center crosshair extends
|
||
techTimeline.fromTo(centerH,
|
||
{ scaleX: 0, opacity: 0 },
|
||
{ scaleX: 1, opacity: 1, duration: 0.5, ease: 'power2.out' },
|
||
0.3
|
||
);
|
||
techTimeline.fromTo(centerV,
|
||
{ scaleY: 0, opacity: 0 },
|
||
{ scaleY: 1, opacity: 1, duration: 0.5, ease: 'power2.out' },
|
||
0.35
|
||
);
|
||
|
||
// 5. Dimension lines extend
|
||
techTimeline.fromTo(dimH,
|
||
{ opacity: 0, scaleX: 0 },
|
||
{ opacity: 1, scaleX: 1, transformOrigin: 'center center',
|
||
duration: 0.6, ease: 'power2.out' },
|
||
0.4
|
||
);
|
||
techTimeline.fromTo(dimV,
|
||
{ opacity: 0, scaleY: 0 },
|
||
{ opacity: 1, scaleY: 1, transformOrigin: 'center center',
|
||
duration: 0.6, ease: 'power2.out' },
|
||
0.45
|
||
);
|
||
|
||
// 6. Reference text types in
|
||
techTimeline.fromTo(ref,
|
||
{ opacity: 0, x: -15 },
|
||
{ opacity: 1, x: 0, duration: 0.4, ease: 'power2.out' },
|
||
0.6
|
||
);
|
||
|
||
// 7. Panel image scan reveal
|
||
const panelImg = document.getElementById('panel-img');
|
||
if (panelImg) {
|
||
techTimeline.fromTo(panelImg,
|
||
{ clipPath: 'inset(0 0 100% 0)', filter: 'brightness(1.8) grayscale(100%)' },
|
||
{ clipPath: 'inset(0 0 0% 0)', filter: 'brightness(1) contrast(1) grayscale(0%)',
|
||
duration: 1, ease: 'power3.out' },
|
||
0.05
|
||
);
|
||
}
|
||
}
|
||
|
||
function hideTechOverlay() {
|
||
if (techTimeline) techTimeline.kill();
|
||
if (techOverlay) {
|
||
gsap.to(techOverlay, { opacity: 0, duration: 0.3, onComplete: () => {
|
||
if (techOverlay) techOverlay.remove();
|
||
techOverlay = null;
|
||
}});
|
||
}
|
||
}
|
||
|
||
// ==========================================================
|
||
// 5. PRODUCT PANEL
|
||
// ==========================================================
|
||
|
||
const panel = document.getElementById('product-panel');
|
||
const panelClose = document.getElementById('panel-close');
|
||
const panelCards = document.querySelectorAll('.product-card');
|
||
|
||
const fields = {
|
||
img: document.getElementById('panel-img'),
|
||
index: document.getElementById('panel-index'),
|
||
name: document.getElementById('panel-name'),
|
||
type: document.getElementById('panel-type'),
|
||
mat: document.getElementById('panel-mat'),
|
||
year: document.getElementById('panel-year'),
|
||
status: document.getElementById('panel-status'),
|
||
desc: document.getElementById('panel-desc'),
|
||
specs: document.getElementById('panel-specs'),
|
||
notes: document.getElementById('panel-notes'),
|
||
};
|
||
|
||
// ---- CHECKOUT LOGIC ----
|
||
const checkoutSection = document.getElementById('checkout-section');
|
||
const checkoutToggleBtn = document.getElementById('checkout-toggle-btn');
|
||
const checkoutFormWrap = document.getElementById('checkout-form-wrap');
|
||
const checkoutForm = document.getElementById('checkout-form');
|
||
const checkoutSubmitBtn = document.getElementById('checkout-submit-btn');
|
||
const checkoutPriceEl = document.querySelector('.checkout-price');
|
||
let currentStripeKey = null;
|
||
|
||
function formatPrice(cents) {
|
||
return (cents / 100).toLocaleString('fr-FR') + ' €';
|
||
}
|
||
|
||
if (checkoutToggleBtn) {
|
||
checkoutToggleBtn.addEventListener('click', () => {
|
||
const isOpen = checkoutFormWrap.style.display !== 'none';
|
||
checkoutFormWrap.style.display = isOpen ? 'none' : 'block';
|
||
checkoutToggleBtn.textContent = isOpen
|
||
? '[ COMMANDER CETTE PIÈCE ]'
|
||
: '[ ANNULER ]';
|
||
});
|
||
}
|
||
|
||
if (checkoutForm) {
|
||
checkoutForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
if (!currentStripeKey) return;
|
||
const email = document.getElementById('checkout-email').value;
|
||
|
||
checkoutSubmitBtn.disabled = true;
|
||
checkoutSubmitBtn.textContent = 'CONNEXION STRIPE...';
|
||
|
||
try {
|
||
const res = await fetch('/api/checkout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ product: currentStripeKey, email }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.url) {
|
||
window.location.href = data.url;
|
||
} else {
|
||
throw new Error('No URL returned');
|
||
}
|
||
} catch (err) {
|
||
checkoutSubmitBtn.disabled = false;
|
||
checkoutSubmitBtn.textContent = 'ERREUR — RÉESSAYER';
|
||
console.error(err);
|
||
}
|
||
});
|
||
}
|
||
|
||
function toSlug(name) {
|
||
return name
|
||
.toLowerCase()
|
||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/_/g, '-')
|
||
.replace(/[^a-z0-9-]/g, '');
|
||
}
|
||
|
||
function openPanel(card, pushState = true) {
|
||
fields.img.src = card.dataset.img;
|
||
fields.img.alt = card.dataset.name;
|
||
fields.index.textContent = card.dataset.index;
|
||
fields.name.textContent = card.dataset.name;
|
||
fields.type.textContent = card.dataset.type;
|
||
fields.mat.textContent = card.dataset.mat;
|
||
fields.year.textContent = card.dataset.year;
|
||
fields.status.textContent = card.dataset.status;
|
||
fields.desc.textContent = card.dataset.desc;
|
||
fields.specs.textContent = card.dataset.specs;
|
||
fields.notes.textContent = card.dataset.notes;
|
||
|
||
// Checkout
|
||
const price = card.dataset.price;
|
||
const stripeKey = card.dataset.stripeKey;
|
||
const isOrderable = price && stripeKey;
|
||
|
||
if (isOrderable) {
|
||
currentStripeKey = stripeKey;
|
||
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
|
||
checkoutSection.style.display = 'block';
|
||
} else {
|
||
currentStripeKey = null;
|
||
checkoutSection.style.display = 'none';
|
||
}
|
||
|
||
checkoutFormWrap.style.display = 'none';
|
||
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
|
||
checkoutSubmitBtn.disabled = false;
|
||
checkoutSubmitBtn.textContent = 'PROCÉDER AU PAIEMENT →';
|
||
checkoutForm.reset();
|
||
|
||
panel.querySelectorAll('details').forEach(d => d.removeAttribute('open'));
|
||
panel.classList.add('is-open');
|
||
panel.setAttribute('aria-hidden', 'false');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
attachCursorHover(panel.querySelectorAll(
|
||
'summary, .panel-close, .checkout-btn, .checkout-submit'
|
||
));
|
||
|
||
// Technical drawing overlay (delayed for panel slide-in)
|
||
createTechOverlay(card);
|
||
setTimeout(() => animateTechOverlay(), 350);
|
||
|
||
if (pushState) {
|
||
const slug = toSlug(card.dataset.name);
|
||
history.pushState({ slug }, '', `/collection/${slug}`);
|
||
}
|
||
}
|
||
|
||
function closePanel(pushState = true) {
|
||
hideTechOverlay();
|
||
panel.classList.remove('is-open');
|
||
panel.setAttribute('aria-hidden', 'true');
|
||
document.body.style.overflow = '';
|
||
|
||
if (pushState) {
|
||
history.pushState({}, '', '/');
|
||
}
|
||
}
|
||
|
||
panelCards.forEach(card => {
|
||
card.addEventListener('click', () => openPanel(card));
|
||
});
|
||
|
||
// Auto-open from direct URL (/collection/[slug])
|
||
if (window.__OPEN_PANEL__) {
|
||
const name = window.__OPEN_PANEL__;
|
||
const card = [...panelCards].find(c => c.dataset.name === name);
|
||
if (card) openPanel(card, false);
|
||
}
|
||
|
||
if (panelClose) panelClose.addEventListener('click', () => closePanel());
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') closePanel();
|
||
});
|
||
|
||
window.addEventListener('popstate', () => {
|
||
if (panel.classList.contains('is-open')) {
|
||
closePanel(false);
|
||
} else {
|
||
const match = location.pathname.match(/^\/collection\/(.+)$/);
|
||
if (match) {
|
||
const slug = match[1];
|
||
const card = [...panelCards].find(c => toSlug(c.dataset.name) === slug);
|
||
if (card) openPanel(card, false);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ==========================================================
|
||
// 6. AMBIENT WORKSHOP SOUND
|
||
// ==========================================================
|
||
|
||
let audioCtx = null;
|
||
let soundOn = false;
|
||
let soundNodes = [];
|
||
let clickTimer = null;
|
||
|
||
const headerNav = document.querySelector('.header-nav');
|
||
if (headerNav) {
|
||
const soundBtn = document.createElement('button');
|
||
soundBtn.className = 'sound-toggle';
|
||
soundBtn.setAttribute('aria-label', 'Activer le son ambiant');
|
||
soundBtn.innerHTML =
|
||
'<span class="sound-bars">' +
|
||
'<span class="sound-bar"></span>' +
|
||
'<span class="sound-bar"></span>' +
|
||
'<span class="sound-bar"></span>' +
|
||
'</span>' +
|
||
'<span class="sound-label">SON</span>';
|
||
soundBtn.addEventListener('click', toggleSound);
|
||
headerNav.appendChild(soundBtn);
|
||
attachCursorHover([soundBtn]);
|
||
}
|
||
|
||
function createAudioContext() {
|
||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
|
||
function generateBrownNoise(ctx, duration) {
|
||
const bufferSize = ctx.sampleRate * duration;
|
||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||
const data = buffer.getChannelData(0);
|
||
let lastOut = 0;
|
||
for (let i = 0; i < bufferSize; i++) {
|
||
const white = Math.random() * 2 - 1;
|
||
data[i] = (lastOut + (0.02 * white)) / 1.02;
|
||
lastOut = data[i];
|
||
data[i] *= 3.5;
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
function startAmbientSound() {
|
||
if (!audioCtx) createAudioContext();
|
||
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||
|
||
const noiseBuffer = generateBrownNoise(audioCtx, 4);
|
||
const noiseSource = audioCtx.createBufferSource();
|
||
noiseSource.buffer = noiseBuffer;
|
||
noiseSource.loop = true;
|
||
|
||
const lowpass = audioCtx.createBiquadFilter();
|
||
lowpass.type = 'lowpass';
|
||
lowpass.frequency.value = 180;
|
||
|
||
const gain = audioCtx.createGain();
|
||
gain.gain.value = 0;
|
||
gain.gain.linearRampToValueAtTime(0.045, audioCtx.currentTime + 2);
|
||
|
||
noiseSource.connect(lowpass);
|
||
lowpass.connect(gain);
|
||
gain.connect(audioCtx.destination);
|
||
noiseSource.start();
|
||
|
||
soundNodes.push({ source: noiseSource, gain: gain });
|
||
scheduleMetalClick();
|
||
}
|
||
|
||
function playMetalClick() {
|
||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||
|
||
const osc = audioCtx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.value = 600 + Math.random() * 2400;
|
||
|
||
const gain = audioCtx.createGain();
|
||
gain.gain.value = 0.006 + Math.random() * 0.014;
|
||
gain.gain.exponentialRampToValueAtTime(
|
||
0.0001,
|
||
audioCtx.currentTime + 0.08 + Math.random() * 0.25
|
||
);
|
||
|
||
const filter = audioCtx.createBiquadFilter();
|
||
filter.type = 'bandpass';
|
||
filter.frequency.value = 1000 + Math.random() * 2000;
|
||
filter.Q.value = 12 + Math.random() * 25;
|
||
|
||
osc.connect(filter);
|
||
filter.connect(gain);
|
||
gain.connect(audioCtx.destination);
|
||
osc.start();
|
||
osc.stop(audioCtx.currentTime + 0.5);
|
||
}
|
||
|
||
function scheduleMetalClick() {
|
||
if (!soundOn) return;
|
||
const delay = 2000 + Math.random() * 7000;
|
||
clickTimer = setTimeout(() => {
|
||
if (!soundOn) return;
|
||
playMetalClick();
|
||
scheduleMetalClick();
|
||
}, delay);
|
||
}
|
||
|
||
function stopAmbientSound() {
|
||
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
||
soundNodes.forEach(({ source, gain }) => {
|
||
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 1);
|
||
setTimeout(() => { try { source.stop(); } catch (e) {} }, 1200);
|
||
});
|
||
soundNodes = [];
|
||
}
|
||
|
||
function toggleSound() {
|
||
soundOn = !soundOn;
|
||
const btn = document.querySelector('.sound-toggle');
|
||
if (!btn) return;
|
||
|
||
if (soundOn) {
|
||
startAmbientSound();
|
||
btn.classList.add('sound-active');
|
||
btn.setAttribute('aria-label', 'Couper le son ambiant');
|
||
} else {
|
||
stopAmbientSound();
|
||
btn.classList.remove('sound-active');
|
||
btn.setAttribute('aria-label', 'Activer le son ambiant');
|
||
}
|
||
}
|
||
|
||
});
|