site-mva-global-fret/index.html
MVA Global Fret 8033c0fd02 Self-host rotating Earth video, project red Paris-Tana line in 3D
Replace the Pexels stock clip with a higher-quality Earth-from-space loop
hosted in /videos (720p, 60s, slowed 2x, 12 MB). The static SVG arc is
gone — the route is now computed every frame from a 3D projection that
follows the globe's rotation:

- France (48.85N, 2.35E) and Antananarivo (-18.9N, 47.5E) are placed on
  a unit sphere, then rotated around the Y axis to match the apparent
  central longitude of the video at each frame (LON0=140 deg, omega
  =-5.75 deg/s, calibrated empirically from sample frames).
- A great-circle arc is sampled with slerp and projected
  orthographically; only the front-side portion (z > 0) is drawn.
- Pins fade out when their city rotates behind the globe; the whole
  group fades out when both endpoints are on the back side.

Mouse parallax dropped — it would desync the SVG from the video and
break alignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 23:41:23 +02:00

251 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MVA Global Fret — Bienvenue</title>
<meta name="description" content="MVA Global Fret — Le pont aérien entre Paris et Antananarivo.">
<link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Poppins:wght@600;700;800&display=swap" rel="stylesheet">
<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">
</head>
<body class="parallax-body">
<header class="parallax-header">
<div class="parallax-logo" aria-label="MVA Global Fret">
<img src="PNG MVA GLOBAL FRET.png" alt="MVA Global Fret">
<span>MVA Global Fret</span>
</div>
<div class="lang-switcher" role="group" aria-label="Choisir la langue">
<button data-lang="fr" class="active">FR</button>
<button data-lang="en">EN</button>
<button data-lang="mg">MG</button>
</div>
</header>
<main class="stage">
<video class="layer layer-video" id="earthVideo"
autoplay loop muted playsinline preload="auto"
poster="videos/earth-poster.jpg">
<source src="videos/earth-rotation.mp4" type="video/mp4">
</video>
<div class="layer layer-tint"></div>
<svg class="layer layer-route" id="routeSvg"
viewBox="0 0 1280 720" preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="redGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="redLine" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff5b6e" stop-opacity="0.7"/>
<stop offset="50%" stop-color="#ff2a3d" stop-opacity="1"/>
<stop offset="100%" stop-color="#ff5b6e" stop-opacity="0.7"/>
</linearGradient>
</defs>
<g id="routeGroup" opacity="0">
<path id="routeHalo" d="" stroke="#ff2a3d" stroke-width="9" fill="none" opacity="0.3" filter="url(#redGlow)"/>
<path id="routeLine" d="" stroke="url(#redLine)" stroke-width="2" fill="none" stroke-linecap="round" filter="url(#redGlow)"/>
<circle id="pulseDot" r="4" fill="#fff" filter="url(#redGlow)"/>
<g id="pinParis">
<circle r="10" fill="#ff2a3d" opacity="0.18"/>
<circle r="5" fill="#ff2a3d" opacity="0.55" filter="url(#redGlow)"/>
<circle r="2.5" fill="#fff"/>
</g>
<g id="pinTana">
<circle r="10" fill="#ff2a3d" opacity="0.18"/>
<circle r="5" fill="#ff2a3d" opacity="0.55" filter="url(#redGlow)"/>
<circle r="2.5" fill="#fff"/>
</g>
</g>
</svg>
<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>
</main>
<script src="js/translations.js"></script>
<script>
/* i18n minimal ------------------------------------------------------- */
(function () {
const lang = localStorage.getItem('mva-lang') || 'fr';
applyLang(lang);
document.querySelectorAll('.lang-switcher button').forEach(btn => {
btn.addEventListener('click', () => applyLang(btn.dataset.lang));
});
function applyLang(l) {
document.documentElement.lang = l;
localStorage.setItem('mva-lang', l);
document.querySelectorAll('.lang-switcher button').forEach(b =>
b.classList.toggle('active', b.dataset.lang === l)
);
const t = window.translations?.[l];
if (!t) return;
document.querySelectorAll('[data-i18n]').forEach(el => {
const keys = el.dataset.i18n.split('.');
let v = t;
for (const k of keys) v = v?.[k];
if (v != null) el.textContent = v;
});
}
})();
/* Ligne rouge 3D synchronisée à la rotation de la Terre --------------
Calibration empirique sur la vidéo earth-rotation.mp4 :
- Centre de la Terre dans le frame 1280×720 : (640, 360)
- Rayon visible : 340 px
- Longitude centrale à t=0 : 140°E
- Vitesse de rotation : -5.75°/s (apparent)
La SVG utilise viewBox 1280×720 avec preserveAspectRatio="xMidYMid slice",
même comportement que le video object-fit:cover → alignement parfait.
-------------------------------------------------------------------- */
(function () {
const EARTH_CX = 640;
const EARTH_CY = 360;
const EARTH_R = 340;
const LON0 = 140; // central longitude visible à t=0
const ROT_RATE = -5.75; // °/s (négatif = view's central decreases)
const PARIS = { lat: 48.85, lon: 2.35 };
const TANA = { lat: -18.9, lon: 47.5 };
const video = document.getElementById('earthVideo');
const routeGroup = document.getElementById('routeGroup');
const halo = document.getElementById('routeHalo');
const line = document.getElementById('routeLine');
const pulseDot = document.getElementById('pulseDot');
const pinParis = document.getElementById('pinParis');
const pinTana = document.getElementById('pinTana');
const DEG = Math.PI / 180;
// 3D unit-sphere vector (y axis = north pole, rotation around y)
function toCart(latDeg, lonDeg) {
const la = latDeg * DEG, lo = lonDeg * DEG;
return [Math.cos(la) * Math.sin(lo),
Math.sin(la),
Math.cos(la) * Math.cos(lo)];
}
function rotateY(v, angDeg) {
const a = angDeg * DEG, c = Math.cos(a), s = Math.sin(a);
return [v[0]*c + v[2]*s, v[1], -v[0]*s + v[2]*c];
}
function project(v) {
return {
x: EARTH_CX + v[0] * EARTH_R,
y: EARTH_CY - v[1] * EARTH_R,
z: v[2] // > 0 = front side
};
}
// Slerp on great circle
function slerp(a, b, t) {
const dot = Math.max(-1, Math.min(1, a[0]*b[0] + a[1]*b[1] + a[2]*b[2]));
const omega = Math.acos(dot);
if (omega < 1e-6) return [a[0], a[1], a[2]];
const s = Math.sin(omega);
const c1 = Math.sin((1 - t) * omega) / s;
const c2 = Math.sin(t * omega) / s;
return [a[0]*c1 + b[0]*c2, a[1]*c1 + b[1]*c2, a[2]*c1 + b[2]*c2];
}
const parisVec = toCart(PARIS.lat, PARIS.lon);
const tanaVec = toCart(TANA.lat, TANA.lon);
// Pre-compute great-circle samples in original (unrotated) space
const SAMPLES = 60;
const arcVecs = [];
for (let i = 0; i <= SAMPLES; i++) arcVecs.push(slerp(parisVec, tanaVec, i / SAMPLES));
function update() {
// Time source : video.currentTime (loops automatically). Fallback : Date.now()
const t = (video && !isNaN(video.currentTime)) ? video.currentTime : (performance.now() / 1000);
// We rotate the world by -(LON0 + ROT_RATE * t) so that the visible
// central longitude at time t equals (LON0 + ROT_RATE * t).
const rotAngle = -(LON0 + ROT_RATE * t);
const pPar = project(rotateY(parisVec, rotAngle));
const pTan = project(rotateY(tanaVec, rotAngle));
// Build the visible portion of the arc
let pathD = '';
let allPoints = [];
let visibleCount = 0;
for (const v of arcVecs) {
const p = project(rotateY(v, rotAngle));
allPoints.push(p);
if (p.z > 0) visibleCount++;
}
if (visibleCount === arcVecs.length) {
// Fully visible → smooth single path
pathD = 'M ' + allPoints[0].x.toFixed(1) + ' ' + allPoints[0].y.toFixed(1);
for (let i = 1; i < allPoints.length; i++) {
pathD += ' L ' + allPoints[i].x.toFixed(1) + ' ' + allPoints[i].y.toFixed(1);
}
} else {
// Partial → only emit the consecutive visible run(s)
let segOpen = false;
for (let i = 0; i < allPoints.length; i++) {
const p = allPoints[i];
if (p.z > 0) {
if (!segOpen) {
pathD += (pathD ? ' M ' : 'M ') + p.x.toFixed(1) + ' ' + p.y.toFixed(1);
segOpen = true;
} else {
pathD += ' L ' + p.x.toFixed(1) + ' ' + p.y.toFixed(1);
}
} else {
segOpen = false;
}
}
}
line.setAttribute('d', pathD);
halo.setAttribute('d', pathD);
// Pin positions + visibility
pinParis.setAttribute('transform', 'translate(' + pPar.x.toFixed(1) + ' ' + pPar.y.toFixed(1) + ')');
pinTana .setAttribute('transform', 'translate(' + pTan.x.toFixed(1) + ' ' + pTan.y.toFixed(1) + ')');
pinParis.style.opacity = pPar.z > 0 ? 1 : 0;
pinTana .style.opacity = pTan.z > 0 ? 1 : 0;
// Pulse dot — travels Paris → Tana along visible portion in 3.6s loops
const pulseT = (t / 3.6) % 1;
let idx = Math.floor(pulseT * SAMPLES);
if (idx > SAMPLES) idx = SAMPLES;
const pPulse = allPoints[idx];
if (pPulse && pPulse.z > 0) {
pulseDot.setAttribute('cx', pPulse.x.toFixed(1));
pulseDot.setAttribute('cy', pPulse.y.toFixed(1));
pulseDot.style.opacity = Math.min(1, pPulse.z * 2);
} else {
pulseDot.style.opacity = 0;
}
// Group opacity = fade out when both pins are on the back side
const visFactor = Math.max(0, Math.min(1, (Math.max(pPar.z, pTan.z) + 0.05) * 4));
routeGroup.setAttribute('opacity', visFactor.toFixed(2));
requestAnimationFrame(update);
}
requestAnimationFrame(update);
})();
</script>
</body>
</html>