Drop the red Paris-Tana line, add mouse parallax to the Earth video

Tracking the line against the rotating globe was finicky and never
quite aligned. Strip out the SVG route, the great-circle slerp, the
3D projection and the debug overlay — the rotating Earth alone is
the visual now.

Re-add a subtle mouse parallax: the video shifts up to 22px against
the cursor, the CTA button tilts 8px the opposite way for a 3D feel.
On mobile, deviceorientation drives the same vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MVA Global Fret 2026-05-05 00:04:37 +02:00
parent eb01e75a3f
commit c52ac514b1
2 changed files with 37 additions and 239 deletions

View File

@ -11,6 +11,8 @@
--gold-light: #e0c98a;
--red: #ff2a3d;
--white: #fff;
--mx: 0;
--my: 0;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -90,19 +92,21 @@ html, body {
overflow: hidden;
}
/* Video et SVG partagent EXACTEMENT le même cadrage object-fit:cover.
C'est ce qui garantit que la ligne 3D reste alignée avec la Terre. */
.layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
will-change: transform;
}
/* Parallaxe vidéo : suit la souris avec easing. Sur-dimensionnée pour
masquer les bords quand elle bouge. */
.layer-video {
object-fit: cover;
object-position: center;
transform: translate(calc(var(--mx) * -22px), calc(var(--my) * -22px)) scale(1.06);
}
.layer-tint {
@ -111,16 +115,16 @@ html, body {
linear-gradient(180deg, rgba(5, 5, 24, 0.25) 0%, rgba(5, 5, 24, 0.5) 100%);
}
.layer-route {
display: block;
}
/* ── BOUTON CTA centré ──────────────────────────────────────────────────── */
.cta-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* Bouge légèrement en sens INVERSE des couches → effet de tilt 3D */
transform: translate(
calc(-50% + var(--mx) * 8px),
calc(-50% + var(--my) * 8px)
);
z-index: 10;
display: inline-flex;
align-items: center;
@ -145,7 +149,10 @@ html, body {
}
.cta-btn:hover {
transform: translate(-50%, -50%) scale(1.04);
transform: translate(
calc(-50% + var(--mx) * 8px),
calc(-50% + var(--my) * 8px)
) scale(1.04);
box-shadow:
0 28px 75px rgba(197, 165, 90, 0.7),
0 0 0 12px rgba(197, 165, 90, 0.12),

View File

@ -36,49 +36,6 @@
<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>
@ -113,199 +70,33 @@
}
})();
/* 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
-------------------------------------------------------------------- */
/* Parallaxe souris : la vidéo et le bouton se décalent légèrement.
--mx, --my sont des floats dans [-1, +1] mappés sur la position
souris, avec easing. Sur mobile, on utilise l'orientation du device. */
(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 root = document.documentElement;
let targetX = 0, targetY = 0, currentX = 0, currentY = 0;
const ease = 0.08;
const PARIS = { lat: 48.85, lon: 2.35 };
const TANA = { lat: -18.9, lon: 47.5 };
function loop() {
currentX += (targetX - currentX) * ease;
currentY += (targetY - currentY) * ease;
root.style.setProperty('--mx', currentX.toFixed(4));
root.style.setProperty('--my', currentY.toFixed(4));
requestAnimationFrame(loop);
}
loop();
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');
window.addEventListener('mousemove', (e) => {
targetX = (e.clientX / window.innerWidth - 0.5) * 2;
targetY = (e.clientY / window.innerHeight - 0.5) * 2;
}, { passive: true });
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);
window.addEventListener('deviceorientation', (e) => {
if (e.gamma == null || e.beta == null) return;
targetX = Math.max(-1, Math.min(1, e.gamma / 30));
targetY = Math.max(-1, Math.min(1, (e.beta - 45) / 30));
}, { passive: true });
})();
</script>
</body>