The pins were drifting east of the actual cities — Paris ended up over Italy and Antananarivo in the Indian Ocean. Linear regression over five calibration frames (visible central longitude at t=0,10,15,18,22) gives LON0~151, rate~-6.0, which I round to 150 and -6. Also expose all knobs as URL params (?lon0=&rate=&cx=&cy=&r=) and add a ?debug=1 overlay that draws the projected sphere outline + equator + 30 deg meridian grid, so the calibration can be eyeballed live. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
313 lines
13 KiB
HTML
313 lines
13 KiB
HTML
<!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="debugLayer" style="display:none">
|
||
<circle id="dbgEarth" r="0" cx="0" cy="0" fill="none" stroke="#00e5ff" stroke-width="1.5" opacity="0.7"/>
|
||
<path id="dbgEquator" d="" stroke="#ffea00" stroke-width="1" fill="none" opacity="0.7" stroke-dasharray="4 4"/>
|
||
<g id="dbgGrid"></g>
|
||
<text id="dbgLabel" x="20" y="40" fill="#00e5ff" font-family="monospace" font-size="18" font-weight="700"></text>
|
||
</g>
|
||
|
||
<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 : 150°E
|
||
- Vitesse de rotation : -6°/s (apparent)
|
||
Override en URL : ?lon0=150&rate=-6&debug=1
|
||
-------------------------------------------------------------------- */
|
||
(function () {
|
||
const params = new URLSearchParams(location.search);
|
||
const EARTH_CX = +params.get('cx') || 640;
|
||
const EARTH_CY = +params.get('cy') || 360;
|
||
const EARTH_R = +params.get('r') || 340;
|
||
const LON0 = parseFloat(params.get('lon0') ?? '150');
|
||
const ROT_RATE = parseFloat(params.get('rate') ?? '-6');
|
||
const DEBUG = params.has('debug');
|
||
|
||
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));
|
||
|
||
// Debug overlay : projected sphere outline + equator + longitude grid
|
||
const dbgLayer = document.getElementById('debugLayer');
|
||
const dbgEarth = document.getElementById('dbgEarth');
|
||
const dbgEquator = document.getElementById('dbgEquator');
|
||
const dbgGrid = document.getElementById('dbgGrid');
|
||
const dbgLabel = document.getElementById('dbgLabel');
|
||
if (DEBUG) {
|
||
dbgLayer.style.display = '';
|
||
dbgEarth.setAttribute('cx', EARTH_CX);
|
||
dbgEarth.setAttribute('cy', EARTH_CY);
|
||
dbgEarth.setAttribute('r', EARTH_R);
|
||
}
|
||
function buildArcPath(vecs, rotAngle) {
|
||
let d = '', open = false;
|
||
for (const v of vecs) {
|
||
const p = project(rotateY(v, rotAngle));
|
||
if (p.z > 0) {
|
||
d += (open ? ' L ' : 'M ') + p.x.toFixed(1) + ' ' + p.y.toFixed(1);
|
||
open = true;
|
||
} else { open = false; }
|
||
}
|
||
return d;
|
||
}
|
||
// Pre-compute equator + meridians (every 30°)
|
||
const eqVecs = [];
|
||
for (let i = 0; i <= 180; i++) eqVecs.push(toCart(0, i * 2 - 180));
|
||
const meridianSets = [];
|
||
for (let lon = -180; lon < 180; lon += 30) {
|
||
const set = [];
|
||
for (let lat = -90; lat <= 90; lat += 5) set.push(toCart(lat, lon));
|
||
meridianSets.push(set);
|
||
if (DEBUG) {
|
||
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
p.setAttribute('stroke', '#ffea00');
|
||
p.setAttribute('stroke-width', '0.6');
|
||
p.setAttribute('fill', 'none');
|
||
p.setAttribute('opacity', '0.5');
|
||
p.setAttribute('stroke-dasharray', '3 3');
|
||
p.dataset.lon = lon;
|
||
dbgGrid.appendChild(p);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Debug overlay update
|
||
if (DEBUG) {
|
||
dbgEquator.setAttribute('d', buildArcPath(eqVecs, rotAngle));
|
||
const meridianPaths = dbgGrid.children;
|
||
for (let i = 0; i < meridianPaths.length; i++) {
|
||
meridianPaths[i].setAttribute('d', buildArcPath(meridianSets[i], rotAngle));
|
||
}
|
||
const central = ((LON0 + ROT_RATE * t) % 360 + 540) % 360 - 180;
|
||
dbgLabel.textContent = 't=' + t.toFixed(1) + 's central=' + central.toFixed(1) + '° rate=' + ROT_RATE + '°/s lon0=' + LON0;
|
||
}
|
||
|
||
// 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>
|