Swap video bg for aerial Antananarivo image, drive plane with the mouse

User-supplied aerial illustration of Antananarivo (Lake Anosy + Rova
hill + surrounding city, 1920px wide JPG, 241 KB) replaces the
parachute-drop video as the static intro backdrop. The video files
are deleted from /videos.

The plane no longer orbits a scroll timeline. Now:
- Page is a single viewport, no scroll, no act labels, no scroll hint.
- Mouse X (0..1) drives plane.position.x from -16 (offscreen left) to
  +16 (offscreen right), with plane.position.y descending from +5 to
  -2 — so the plane enters from the upper-left and exits lower-right.
- Pitch/roll/yaw lerp toward small targets that depend on mouse X, so
  the plane banks naturally as it crosses.
- Background image gets a softer mouse parallax (-16/-10px) via the
  existing --mx/--my CSS vars, now updated from intro-scene.js.
- Three.js cloud spheres are gone; the photo is the entire backdrop.
- ScrollTrigger + the GSAP timeline are removed; the page no longer
  needs gsap at all (the script tag stayed for now in case it comes
  back, but the dependency could be dropped on a future pass).
- CTA button is back to plain visible/centered, no reveal animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MVA Global Fret 2026-05-05 11:35:11 +02:00
parent d99e2a5fc1
commit eba88207c4
6 changed files with 78 additions and 220 deletions

BIN
assets/antananarivo-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

View File

@ -1,7 +1,8 @@
/* =========================================================================
PARALLAX INTRO MVA Global Fret
Page scrollable (4 viewports) avec scène Three.js fixée. La timeline
pilote la caméra et l'avion 3D. À 100% du scroll, le bouton CTA apparaît.
Page unique. Photo aérienne d'Antananarivo en fond + avion 3D piloté
par la souris (entre par le haut-gauche, sort par la droite). Bouton
CTA doré centré, toujours visible.
========================================================================= */
:root {
@ -22,12 +23,10 @@ html, body {
background: var(--navy-deep);
}
html { scroll-behavior: smooth; }
body.parallax-body {
.parallax-body {
font-family: 'Inter', sans-serif;
color: var(--white);
overflow-x: hidden;
overflow: hidden;
}
/* ── HEADER ─────────────────────────────────────────────────────────────── */
@ -86,16 +85,12 @@ body.parallax-body {
}
.lang-switcher button:hover:not(.active) { color: var(--white); }
/* SCÈNE FIXE (vidéo + voile + canvas Three.js)
.stage-fixed reste plein-écran pendant que la page se déroule derrière.
*/
.stage-fixed {
position: fixed;
inset: 0;
/* ── STAGE ──────────────────────────────────────────────────────────────── */
.stage {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
z-index: 1;
}
.layer {
@ -107,90 +102,20 @@ body.parallax-body {
will-change: transform;
}
.layer-video {
/* Photo de fond — léger zoom + parallaxe souris discrète */
.layer-bg {
object-fit: cover;
object-position: center;
transform: translate(calc(var(--mx) * -22px), calc(var(--my) * -22px)) scale(1.06);
transform: translate(calc(var(--mx) * -16px), calc(var(--my) * -10px)) scale(1.05);
}
.layer-tint {
background:
radial-gradient(ellipse at center, transparent 0%, rgba(5, 5, 24, 0.55) 100%),
linear-gradient(180deg, rgba(5, 5, 24, 0.25) 0%, rgba(5, 5, 24, 0.5) 100%);
radial-gradient(ellipse at center, transparent 0%, rgba(5, 5, 24, 0.32) 100%),
linear-gradient(180deg, transparent 55%, rgba(5, 5, 24, 0.40) 100%);
}
.layer-three {
display: block;
z-index: 2;
}
/* ── SCROLL STAGE — sentinelles invisibles qui donnent la hauteur ──────── */
.scroll-stage {
position: relative;
z-index: 5;
pointer-events: none;
}
.scroll-stage .act {
height: 100vh;
}
/* ── ÉTIQUETTES D'ACTE ──────────────────────────────────────────────────── */
.act-label {
position: absolute;
left: 50%;
bottom: 18%;
transform: translateX(-50%);
z-index: 30;
pointer-events: none;
text-align: center;
font-family: 'Poppins', sans-serif;
font-weight: 700;
letter-spacing: 4px;
text-transform: uppercase;
font-size: 1.05rem;
color: var(--white);
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.7);
opacity: 0;
}
.act-label span {
display: inline-block;
padding: 14px 28px;
background: rgba(5, 5, 24, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
line-height: 1.35;
}
/* ── INDICATEUR SCROLL ─────────────────────────────────────────────────── */
.scroll-hint {
position: absolute;
left: 50%;
bottom: 36px;
transform: translateX(-50%);
z-index: 40;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-family: 'Poppins', sans-serif;
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
animation: scrollBob 1.8s ease-in-out infinite;
transition: opacity 0.5s ease;
}
.scroll-hint.hidden { opacity: 0; pointer-events: none; }
.scroll-hint i { font-size: 1rem; }
@keyframes scrollBob {
0%, 100% { transform: translate(-50%, 0); }
50% { transform: translate(-50%, 8px); }
}
.layer-three { display: block; z-index: 2; }
/* ── BOUTON CTA centré ──────────────────────────────────────────────────── */
.cta-btn {
@ -198,7 +123,7 @@ body.parallax-body {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 50;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 16px;
@ -219,11 +144,7 @@ body.parallax-body {
inset 0 1px 0 rgba(255, 255, 255, 0.45);
transition: box-shadow 0.32s cubic-bezier(0.2, 0.8, 0.2, 1),
transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
/* Caché au départ ; révélé via GSAP en fin de scroll */
opacity: 0;
pointer-events: none;
}
.cta-btn.revealed { pointer-events: auto; }
.cta-btn:hover {
transform: translate(-50%, -50%) scale(1.04);
@ -275,13 +196,8 @@ body.parallax-body {
padding: 16px 36px;
font-size: 0.98rem;
}
.act-label { font-size: 0.85rem; bottom: 22%; }
}
@media (prefers-reduced-motion: reduce) {
.cta-btn::after { animation: none; }
.scroll-hint { animation: none; display: none; }
.scroll-stage { display: none; }
.cta-btn { opacity: 1; pointer-events: auto; }
html { scroll-behavior: auto; }
}

View File

@ -18,7 +18,7 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="css/parallax.css">
<!-- Three.js (ESM via importmap) + addons (GLTFLoader, etc.) -->
<!-- Three.js (ESM via importmap) + addons (GLTFLoader) -->
<script type="importmap">
{
"imports": {
@ -27,9 +27,6 @@
}
}
</script>
<!-- GSAP + ScrollTrigger (UMD) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
</head>
<body class="parallax-body">
@ -45,43 +42,24 @@
</div>
</header>
<!-- Couches fixes : vidéo + voile + canvas Three.js (le plan 3D vole dessus) -->
<div class="stage-fixed">
<video class="layer layer-video" id="introVideo"
autoplay loop muted playsinline preload="auto"
poster="videos/parachute-poster.jpg">
<source src="videos/parachute-drop.mp4" type="video/mp4">
</video>
<main class="stage">
<!-- Photo aérienne d'Antananarivo en fond -->
<img class="layer layer-bg" src="assets/antananarivo-bg.jpg" alt="" aria-hidden="true">
<!-- Voile très léger pour la lisibilité du bouton -->
<div class="layer layer-tint"></div>
<!-- Canvas Three.js : avion piloté par la souris -->
<canvas id="three-canvas" class="layer layer-three"></canvas>
<!-- Étiquettes des actes — apparaissent / disparaissent selon le scroll -->
<div class="act-label" id="label-paris"><span>Paris · CDG</span></div>
<div class="act-label" id="label-cruise"><span>Vol cargo<br>10 000 km</span></div>
<div class="act-label" id="label-tana"><span>Antananarivo</span></div>
<!-- Indicateur scroll (visible jusqu'à ce qu'on scrolle) -->
<div class="scroll-hint" id="scrollHint">
<span data-i18n="intro.scrollHint">Faites défiler</span>
<i class="fa-solid fa-chevron-down"></i>
</div>
<!-- CTA — caché jusqu'à la fin du scroll -->
<a href="accueil.html" class="cta-btn" id="ctaBtn">
<!-- Bouton CTA centré -->
<a href="accueil.html" class="cta-btn">
<span class="cta-btn-shine"></span>
<span data-i18n="intro.ctaBtn">Accéder au site</span>
<i class="fa-solid fa-arrow-right"></i>
</a>
</div>
<!-- Sentinelles invisibles : leur scroll-position pilote la timeline -->
<main class="scroll-stage" aria-hidden="true">
<section class="act"></section>
<section class="act"></section>
<section class="act"></section>
<section class="act"></section>
</main>
<script src="js/translations.js"></script>
@ -109,11 +87,6 @@
});
}
})();
/* Scroll-hint : disparaît dès qu'on commence à scroller */
window.addEventListener('scroll', () => {
document.getElementById('scrollHint')?.classList.add('hidden');
}, { once: true, passive: true });
</script>
<script type="module" src="js/intro-scene.js"></script>

View File

@ -1,20 +1,16 @@
/* =========================================================================
INTRO SCENE MVA Global Fret
Three.js scene driven by scroll. The cargo airliner is a GLTF model
loaded at runtime; GSAP + ScrollTrigger animate the camera position
and exposition labels as the page scrolls. The CTA fades in at the
end of the timeline.
Photo aérienne d'Antananarivo en fond, avion 3D piloté par la souris :
il entre par le haut-gauche quand la souris est à gauche, traverse
l'écran à mesure qu'elle bouge, et sort par la droite. Pas de scroll.
3D model credit (CC-BY): "Airplane" by Poly by Google
3D model credit (CC-BY 3.0) : « Airplane » by Poly by Google
https://poly.pizza/m/a3XrQkLNna9
========================================================================= */
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const { gsap, ScrollTrigger } = window;
gsap.registerPlugin(ScrollTrigger);
/* ── Renderer & camera ─────────────────────────────────────────────────── */
const canvas = document.getElementById('three-canvas');
const renderer = new THREE.WebGLRenderer({
@ -27,8 +23,8 @@ renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 1.4, 18);
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 22);
camera.lookAt(0, 0, 0);
/* ── Lighting (golden-hour fill) ───────────────────────────────────────── */
@ -51,9 +47,6 @@ loader.load(
'assets/airplane.glb',
(gltf) => {
const model = gltf.scene;
/* Compute bbox at native scale, then offset model so its center sits
at the wrapper's origin. The wrapper scales everything together,
so the center offset stays valid after scaling. */
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
@ -63,95 +56,71 @@ loader.load(
wrapper.add(model);
const targetSize = 8.5;
wrapper.scale.setScalar(targetSize / Math.max(size.x, size.y, size.z));
/* The Poly by Google plane sits with nose along +X; turn it so the
nose faces the camera at the start of the timeline. */
wrapper.rotation.y = Math.PI / 2;
/* Pivote le modèle pour que le nez pointe vers la droite (+X) */
wrapper.rotation.y = -Math.PI / 2;
planeHolder.add(wrapper);
},
undefined,
(err) => console.error('Failed to load airplane.glb:', err)
);
/* ── Cloud sprites ─────────────────────────────────────────────────────── */
const clouds = new THREE.Group();
const cloudMat = new THREE.MeshStandardMaterial({
color: 0xffffff, roughness: 1.0, metalness: 0, transparent: true, opacity: 0.85
});
for (let i = 0; i < 14; i++) {
const cl = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 10), cloudMat);
const r = 18 + Math.random() * 14;
const a = Math.random() * Math.PI * 2;
cl.position.set(Math.cos(a) * r, (Math.random() - 0.4) * 9, Math.sin(a) * r - 8);
cl.scale.set(1.6 + Math.random() * 1.4, 0.9 + Math.random() * 0.6, 1.6 + Math.random() * 1.4);
clouds.add(cl);
}
scene.add(clouds);
/* Souris
- mouseX, mouseY : 0..1 normalisés
- Sur écran sans souris (touch/mobile), valeur lente d'auto-scroll
*/
const mouse = { tx: 0.0, ty: 0.5, x: 0.0, y: 0.5 };
/* ── Scroll-driven progress ────────────────────────────────────────────── */
const state = { progress: 0 };
window.addEventListener('mousemove', (e) => {
mouse.tx = Math.max(0, Math.min(1, e.clientX / window.innerWidth));
mouse.ty = Math.max(0, Math.min(1, e.clientY / window.innerHeight));
}, { passive: true });
ScrollTrigger.create({
trigger: '.scroll-stage',
start: 'top top',
end: 'bottom bottom',
scrub: 0.6,
onUpdate: self => { state.progress = self.progress; }
});
/* Cross-fade des étiquettes d'acte */
const labelTl = gsap.timeline({
scrollTrigger: { trigger: '.scroll-stage', start: 'top top', end: 'bottom bottom', scrub: 0.4 }
});
labelTl
.fromTo('#label-paris', { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.05 }, 0.02)
.to( '#label-paris', { opacity: 0, y: -20 }, 0.20)
.fromTo('#label-cruise', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.40)
.to( '#label-cruise', { opacity: 0, y: -20 }, 0.60)
.fromTo('#label-tana', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.72)
.to( '#label-tana', { opacity: 0, y: -20 }, 0.92);
/* CTA reveal — last ~10% of scroll */
gsap.fromTo('#ctaBtn',
{ opacity: 0, scale: 0.85 },
{
opacity: 1, scale: 1,
scrollTrigger: {
trigger: '.scroll-stage',
start: 'bottom-=400 bottom',
end: 'bottom bottom',
scrub: 0.4,
onLeave: () => document.getElementById('ctaBtn').classList.add('revealed'),
onEnterBack: () => document.getElementById('ctaBtn').classList.remove('revealed'),
/* Sur mobile : utilise l'orientation gamma (gauche-droite) si dispo */
window.addEventListener('deviceorientation', (e) => {
if (e.gamma == null) return;
mouse.tx = Math.max(0, Math.min(1, (e.gamma + 30) / 60));
if (e.beta != null) {
mouse.ty = Math.max(0, Math.min(1, (e.beta - 20) / 60));
}
}
);
}, { passive: true });
/* Variable CSS pour la parallaxe douce de la photo de fond */
const root = document.documentElement;
/* ── Render loop ───────────────────────────────────────────────────────── */
const clock = new THREE.Clock();
function tick() {
const t = clock.getElapsedTime();
const p = state.progress;
/* Camera arc around the plane */
const camAngle = -0.6 + p * 1.7;
const camRadius = 18 - p * 6 + Math.sin(p * Math.PI) * -3;
const camHeight = 1.4 + Math.sin(p * Math.PI) * 1.8 - p * 0.6;
/* Lerp doux vers la cible souris */
mouse.x += (mouse.tx - mouse.x) * 0.06;
mouse.y += (mouse.ty - mouse.y) * 0.06;
camera.position.set(
Math.sin(camAngle) * camRadius,
camHeight,
Math.cos(camAngle) * camRadius
);
camera.lookAt(0, 0, 0);
/* Mappe sur les variables CSS (parallaxe légère du fond) */
root.style.setProperty('--mx', ((mouse.x - 0.5) * 2).toFixed(4));
root.style.setProperty('--my', ((mouse.y - 0.5) * 2).toFixed(4));
/* Plane bob + roll */
planeHolder.position.y = Math.sin(t * 0.9) * 0.15;
planeHolder.rotation.z = Math.sin(t * 0.5) * 0.04 + (p - 0.5) * 0.18;
planeHolder.rotation.y = Math.sin(t * 0.4) * 0.025;
/* Position de l'avion :
- mouse.x = 0 arrive haut-gauche (hors champ)
- mouse.x = 0.5 centré
- mouse.x = 1 sortie en bas-droite (hors champ)
*/
const px = -16 + mouse.x * 32; // -16 à +16
const py = 5 - mouse.x * 7; // descend de +5 à -2 selon X
/* Léger bobbing autonome pour qu'il reste vivant même sans bouger la souris */
const bob = Math.sin(t * 0.9) * 0.12;
/* Clouds slow rotation */
clouds.rotation.y = t * 0.04 - p * 0.6;
clouds.position.y = Math.sin(t * 0.3) * 0.4;
planeHolder.position.set(px, py + bob, 0);
/* Roulis : penche dans le sens du mouvement (mais tourne le nez vers la droite) */
const targetRoll = -0.18 - (mouse.x - 0.5) * 0.25; // léger pivot quand on traverse
const targetPitch = -0.18 - mouse.x * 0.10; // légèrement piqué
const targetYaw = (mouse.x - 0.5) * 0.10; // soupçon de yaw
planeHolder.rotation.z += (targetRoll - planeHolder.rotation.z) * 0.08;
planeHolder.rotation.x += (targetPitch - planeHolder.rotation.x) * 0.08;
planeHolder.rotation.y += (targetYaw - planeHolder.rotation.y) * 0.08;
renderer.render(scene, camera);
requestAnimationFrame(tick);

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB