feat: WebGL cinematic countdown — 3D atom with circular orbital text
- Three.js + React Three Fiber + postprocessing (bloom, chromatic aberration, glitch, scanlines) - Nucleus with pulsing energy core, wireframe icosahedron shell - 3 electron orbits with trail particles + energy arcs - 600 particle cloud with organic movement - Circular orbital text: title, countdown timer, subtitle curved on 3D rings - Mouse interactions: camera parallax, atom speed boost, nucleus glow on proximity - Camera smooth lerp follow with gentle auto-drift - Font fix: woff2 → ttf for troika-three-text compatibility - Logo: static mark instead of missing sprite frames Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
666f05bf59
commit
d5f1ce84b4
@@ -0,0 +1,526 @@
|
||||
import { useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { EffectComposer, Bloom, ChromaticAberration, Scanline, Glitch as GlitchEffect } from '@react-three/postprocessing';
|
||||
import { GlitchMode, BlendFunction } from 'postprocessing';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/* ===================================================================
|
||||
ATOM GLITCH — cinematic WebGL countdown for L'Electron Rare
|
||||
- Nucleus core with pulsing energy
|
||||
- 3 electron orbits with trail particles
|
||||
- Particle cloud (electrons as sparks)
|
||||
- Post-processing: bloom, chromatic aberration, glitch, scanlines
|
||||
- Mouse-reactive + auto-orbit
|
||||
=================================================================== */
|
||||
|
||||
const COLORS = {
|
||||
cyan: new THREE.Color('#5bd1d8'),
|
||||
amber: new THREE.Color('#f1c27a'),
|
||||
green: new THREE.Color('#b6d18f'),
|
||||
electric: new THREE.Color('#0071e3'),
|
||||
white: new THREE.Color('#ffffff'),
|
||||
nucleus: new THREE.Color('#ff6b35'),
|
||||
};
|
||||
|
||||
/* ---------- Particle cloud around the atom ---------- */
|
||||
function ParticleField({ count = 600 }) {
|
||||
const ref = useRef<THREE.Points>(null);
|
||||
|
||||
const { positions, velocities, colors } = useMemo(() => {
|
||||
const pos = new Float32Array(count * 3);
|
||||
const vel = new Float32Array(count * 3);
|
||||
const col = new Float32Array(count * 3);
|
||||
const palette = [COLORS.cyan, COLORS.amber, COLORS.green, COLORS.electric];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = 2 + Math.random() * 6;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
pos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
pos[i * 3 + 2] = r * Math.cos(phi);
|
||||
|
||||
vel[i * 3] = (Math.random() - 0.5) * 0.004;
|
||||
vel[i * 3 + 1] = (Math.random() - 0.5) * 0.004;
|
||||
vel[i * 3 + 2] = (Math.random() - 0.5) * 0.004;
|
||||
|
||||
const c = palette[Math.floor(Math.random() * palette.length)];
|
||||
col[i * 3] = c.r;
|
||||
col[i * 3 + 1] = c.g;
|
||||
col[i * 3 + 2] = c.b;
|
||||
}
|
||||
return { positions: pos, velocities: vel, colors: col };
|
||||
}, [count]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
const posAttr = ref.current.geometry.attributes.position as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
const t = clock.getElapsedTime();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
arr[i * 3] += velocities[i * 3] + Math.sin(t * 0.5 + i) * 0.002;
|
||||
arr[i * 3 + 1] += velocities[i * 3 + 1] + Math.cos(t * 0.3 + i) * 0.002;
|
||||
arr[i * 3 + 2] += velocities[i * 3 + 2];
|
||||
|
||||
// respawn far particles
|
||||
const dist = Math.sqrt(arr[i * 3] ** 2 + arr[i * 3 + 1] ** 2 + arr[i * 3 + 2] ** 2);
|
||||
if (dist > 8) {
|
||||
const r = 2 + Math.random() * 2;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
arr[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
arr[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
arr[i * 3 + 2] = r * Math.cos(phi);
|
||||
}
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={ref}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
|
||||
</bufferGeometry>
|
||||
<pointsMaterial size={0.04} vertexColors transparent opacity={0.7} sizeAttenuation depthWrite={false} blending={THREE.AdditiveBlending} />
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Orbit trail (instanced tube of spheres) ---------- */
|
||||
function OrbitTrail({ tilt, speed, color, trailCount = 30 }: { tilt: number[]; speed: number; color: THREE.Color; trailCount?: number }) {
|
||||
const meshRef = useRef<THREE.InstancedMesh>(null);
|
||||
const phase = useMemo(() => Math.random() * Math.PI * 2, []);
|
||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||
const orbitA = 3.2;
|
||||
const orbitB = orbitA * 0.38;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!meshRef.current) return;
|
||||
const t = clock.getElapsedTime() * speed + phase;
|
||||
|
||||
for (let i = 0; i < trailCount; i++) {
|
||||
const age = i / trailCount;
|
||||
const angle = t - age * 0.8;
|
||||
dummy.position.set(Math.cos(angle) * orbitA, Math.sin(angle) * orbitB, 0);
|
||||
const s = (1 - age) * 0.14 + 0.02;
|
||||
dummy.scale.setScalar(s);
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<group rotation={tilt as [number, number, number]}>
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, trailCount]}>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={2} transparent opacity={0.9} />
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Orbit ring (thin glowing line) ---------- */
|
||||
function OrbitRing({ tilt, color }: { tilt: number[]; color: THREE.Color }) {
|
||||
const points = useMemo(() => {
|
||||
const pts: THREE.Vector3[] = [];
|
||||
for (let i = 0; i <= 128; i++) {
|
||||
const t = (i / 128) * Math.PI * 2;
|
||||
pts.push(new THREE.Vector3(Math.cos(t) * 3.2, Math.sin(t) * 3.2 * 0.38, 0));
|
||||
}
|
||||
return pts;
|
||||
}, []);
|
||||
const geo = useMemo(() => new THREE.BufferGeometry().setFromPoints(points), [points]);
|
||||
|
||||
return (
|
||||
<group rotation={tilt as [number, number, number]}>
|
||||
<line geometry={geo}>
|
||||
<lineBasicMaterial color={color} transparent opacity={0.12} />
|
||||
</line>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Nucleus: pulsing energy core ---------- */
|
||||
function Nucleus() {
|
||||
const coreRef = useRef<THREE.Mesh>(null);
|
||||
const glowRef = useRef<THREE.Mesh>(null);
|
||||
const outerRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame(({ clock, pointer }) => {
|
||||
const t = clock.getElapsedTime();
|
||||
const mouseDist = Math.sqrt(pointer.x ** 2 + pointer.y ** 2);
|
||||
const mouseProximity = Math.max(0, 1 - mouseDist); // 1 at center, 0 at edges
|
||||
|
||||
if (coreRef.current) {
|
||||
const s = 1 + Math.sin(t * 3) * 0.08 + Math.sin(t * 7.3) * 0.03 + mouseProximity * 0.15;
|
||||
coreRef.current.scale.setScalar(s);
|
||||
(coreRef.current.material as THREE.MeshStandardMaterial).emissiveIntensity = 2 + mouseProximity * 3;
|
||||
}
|
||||
if (glowRef.current) {
|
||||
const s = 1 + Math.sin(t * 2) * 0.15 + mouseProximity * 0.3;
|
||||
glowRef.current.scale.setScalar(s);
|
||||
(glowRef.current.material as THREE.MeshStandardMaterial).opacity = 0.15 + Math.sin(t * 4) * 0.08 + mouseProximity * 0.2;
|
||||
}
|
||||
if (outerRef.current) {
|
||||
outerRef.current.rotation.y = t * (0.5 + mouseProximity * 2);
|
||||
outerRef.current.rotation.x = t * (0.3 + mouseProximity * 1.5);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* outer energy shell */}
|
||||
<mesh ref={outerRef}>
|
||||
<icosahedronGeometry args={[0.9, 1]} />
|
||||
<meshStandardMaterial color={COLORS.electric} wireframe transparent opacity={0.08} emissive={COLORS.electric} emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
{/* glow sphere */}
|
||||
<mesh ref={glowRef}>
|
||||
<sphereGeometry args={[0.7, 32, 32]} />
|
||||
<meshStandardMaterial color={COLORS.nucleus} transparent opacity={0.15} emissive={COLORS.nucleus} emissiveIntensity={1.2} />
|
||||
</mesh>
|
||||
{/* solid core */}
|
||||
<mesh ref={coreRef}>
|
||||
<sphereGeometry args={[0.32, 32, 32]} />
|
||||
<meshStandardMaterial color="#ffffff" emissive={COLORS.amber} emissiveIntensity={2} metalness={0.6} roughness={0.2} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Energy arcs (random lightning) ---------- */
|
||||
function EnergyArc({ color }: { color: THREE.Color }) {
|
||||
const ref = useRef<THREE.Line>(null);
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const t = clock.getElapsedTime();
|
||||
// flash randomly
|
||||
setVisible(Math.sin(t * 12 + Math.random() * 100) > 0.7);
|
||||
|
||||
if (ref.current && visible) {
|
||||
const geo = ref.current.geometry;
|
||||
const pts: THREE.Vector3[] = [];
|
||||
const segments = 8;
|
||||
const startAngle = Math.sin(t * 2) * Math.PI;
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const frac = i / segments;
|
||||
const r = 0.4 + frac * 2.5;
|
||||
const angle = startAngle + frac * 1.5;
|
||||
pts.push(new THREE.Vector3(
|
||||
Math.cos(angle) * r + (Math.random() - 0.5) * 0.3,
|
||||
Math.sin(angle) * r * 0.4 + (Math.random() - 0.5) * 0.2,
|
||||
(Math.random() - 0.5) * 0.4,
|
||||
));
|
||||
}
|
||||
geo.setFromPoints(pts);
|
||||
}
|
||||
});
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<line ref={ref}>
|
||||
<bufferGeometry />
|
||||
<lineBasicMaterial color={color} transparent opacity={0.4} blending={THREE.AdditiveBlending} />
|
||||
</line>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 3D Circular / Orbital text ---------- */
|
||||
const LAUNCH = new Date('2026-05-01T00:00:00+02:00').getTime();
|
||||
const FONT_URL = '/assets/fonts/manrope-regular.ttf';
|
||||
|
||||
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||
|
||||
/* Each character placed on a circle arc, rotating together */
|
||||
function CircularText({ text, radius, speed, tilt, fontSize, color, emissive, opacity = 0.9, spread = 0.8 }: {
|
||||
text: string; radius: number; speed: number; tilt: number[];
|
||||
fontSize: number; color: string; emissive: string; opacity?: number; spread?: number;
|
||||
}) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const chars = useMemo(() => text.split(''), [text]);
|
||||
const charWidth = fontSize * 0.55;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!groupRef.current) return;
|
||||
const t = clock.getElapsedTime() * speed;
|
||||
groupRef.current.rotation.y = t;
|
||||
});
|
||||
|
||||
return (
|
||||
<group rotation={tilt as [number, number, number]}>
|
||||
<group ref={groupRef}>
|
||||
{chars.map((char, i) => {
|
||||
const angle = ((i - chars.length / 2) * charWidth * spread) / radius;
|
||||
const x = Math.sin(angle) * radius;
|
||||
const z = Math.cos(angle) * radius;
|
||||
return (
|
||||
<Text
|
||||
key={i}
|
||||
position={[x, 0, z]}
|
||||
rotation={[0, -angle, 0]}
|
||||
fontSize={fontSize}
|
||||
font={FONT_URL}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
>
|
||||
{char}
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={0.6}
|
||||
transparent
|
||||
opacity={opacity}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* Countdown — characters on a spinning ring */
|
||||
function CountdownRing() {
|
||||
const [time, setTime] = useState('');
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const radius = 4.2;
|
||||
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
const diff = Math.max(0, LAUNCH - Date.now());
|
||||
const d = Math.floor(diff / 86400000);
|
||||
const h = Math.floor((diff % 86400000) / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
const s = Math.floor((diff % 60000) / 1000);
|
||||
setTime(`${pad(d)}J ${pad(h)}H ${pad(m)}M ${pad(s)}S`);
|
||||
}
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!groupRef.current) return;
|
||||
groupRef.current.rotation.y = clock.getElapsedTime() * -0.2;
|
||||
});
|
||||
|
||||
const chars = time.split('');
|
||||
const fontSize = 0.28;
|
||||
const charWidth = fontSize * 0.5;
|
||||
|
||||
return (
|
||||
<group rotation={[0.3, 0, 0.1]}>
|
||||
<group ref={groupRef}>
|
||||
{chars.map((char, i) => {
|
||||
const angle = ((i - chars.length / 2) * charWidth * 0.9) / radius;
|
||||
const x = Math.sin(angle) * radius;
|
||||
const z = Math.cos(angle) * radius;
|
||||
return (
|
||||
<Text
|
||||
key={i}
|
||||
position={[x, 0, z]}
|
||||
rotation={[0, -angle, 0]}
|
||||
fontSize={fontSize}
|
||||
font={FONT_URL}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
>
|
||||
{char}
|
||||
<meshStandardMaterial
|
||||
color="#ffffff"
|
||||
emissive="#5bd1d8"
|
||||
emissiveIntensity={0.8}
|
||||
transparent
|
||||
opacity={0.95}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* All circular texts assembled */
|
||||
function CountdownText() {
|
||||
return (
|
||||
<group>
|
||||
{/* "L'electron" — large ring, slow spin */}
|
||||
<CircularText
|
||||
text={"L'\u00e9lectron"}
|
||||
radius={3.8}
|
||||
speed={0.15}
|
||||
tilt={[-0.3, 0, 0]}
|
||||
fontSize={0.55}
|
||||
color="#ffffff"
|
||||
emissive="#ffffff"
|
||||
spread={0.9}
|
||||
/>
|
||||
|
||||
{/* "rare" — opposite tilt, bigger */}
|
||||
<CircularText
|
||||
text="rare"
|
||||
radius={3.5}
|
||||
speed={-0.2}
|
||||
tilt={[0.5, 0.2, 0]}
|
||||
fontSize={0.85}
|
||||
color="#ffffff"
|
||||
emissive="#5bd1d8"
|
||||
spread={1.0}
|
||||
/>
|
||||
|
||||
{/* Subtitle — wider ring, slow */}
|
||||
<CircularText
|
||||
text="SYSTEMES ELECTRONIQUES SPECIFIQUES"
|
||||
radius={5.2}
|
||||
speed={0.08}
|
||||
tilt={[1.3, 0.2, 0.4]}
|
||||
fontSize={0.11}
|
||||
color="#ffffff"
|
||||
emissive="#5bd1d8"
|
||||
opacity={0.35}
|
||||
spread={0.7}
|
||||
/>
|
||||
|
||||
{/* "LANCEMENT DANS" — small ring */}
|
||||
<CircularText
|
||||
text="LANCEMENT DANS"
|
||||
radius={3.2}
|
||||
speed={0.25}
|
||||
tilt={[-0.7, -0.3, 0.2]}
|
||||
fontSize={0.1}
|
||||
color="#5bd1d8"
|
||||
emissive="#5bd1d8"
|
||||
opacity={0.5}
|
||||
spread={0.8}
|
||||
/>
|
||||
|
||||
{/* Countdown timer — spinning ring */}
|
||||
<CountdownRing />
|
||||
|
||||
{/* Extra decorative text rings */}
|
||||
<CircularText
|
||||
text="electronique automatisme energie stockage prototypage formation"
|
||||
radius={5.8}
|
||||
speed={-0.05}
|
||||
tilt={[0.8, -0.4, 0.6]}
|
||||
fontSize={0.07}
|
||||
color="#5bd1d8"
|
||||
emissive="#5bd1d8"
|
||||
opacity={0.2}
|
||||
spread={0.5}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main scene ---------- */
|
||||
function AtomScene() {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
const orbits = useMemo(() => [
|
||||
{ tilt: [-0.5, 0, 0], speed: 1.0, color: COLORS.cyan },
|
||||
{ tilt: [0.5, 0.3, 0], speed: 0.72, color: COLORS.amber },
|
||||
{ tilt: [1.57, 0.2, 0.4], speed: 0.55, color: COLORS.green },
|
||||
], []);
|
||||
|
||||
useFrame(({ clock, pointer }) => {
|
||||
if (!groupRef.current) return;
|
||||
const t = clock.getElapsedTime();
|
||||
// Mouse distance from center = speed boost
|
||||
const mouseDist = Math.sqrt(pointer.x ** 2 + pointer.y ** 2);
|
||||
const speedMul = 1 + mouseDist * 0.8;
|
||||
groupRef.current.rotation.y = t * 0.12 * speedMul + pointer.x * 0.6;
|
||||
groupRef.current.rotation.x = -0.2 + pointer.y * 0.4;
|
||||
groupRef.current.rotation.z = pointer.x * 0.1;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<Nucleus />
|
||||
{orbits.map((o, i) => (
|
||||
<OrbitRing key={`ring-${i}`} tilt={o.tilt} color={o.color} />
|
||||
))}
|
||||
{orbits.map((o, i) => (
|
||||
<OrbitTrail key={`trail-${i}`} tilt={o.tilt} speed={o.speed} color={o.color} />
|
||||
))}
|
||||
<EnergyArc color={COLORS.cyan} />
|
||||
<EnergyArc color={COLORS.amber} />
|
||||
<ParticleField />
|
||||
{/* Textes orbitants — dans le groupe, tournent avec l'atome */}
|
||||
<CountdownText />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Camera with mouse parallax ---------- */
|
||||
function CameraRig() {
|
||||
const { camera } = useThree();
|
||||
const mouse = useRef({ x: 0, y: 0 });
|
||||
const smooth = useRef({ x: 0, y: 0 });
|
||||
|
||||
useFrame(({ clock, pointer }) => {
|
||||
const t = clock.getElapsedTime();
|
||||
|
||||
// Smooth mouse follow (lerp)
|
||||
mouse.current.x = pointer.x;
|
||||
mouse.current.y = pointer.y;
|
||||
smooth.current.x += (mouse.current.x - smooth.current.x) * 0.05;
|
||||
smooth.current.y += (mouse.current.y - smooth.current.y) * 0.05;
|
||||
|
||||
// Camera orbits gently + follows mouse
|
||||
camera.position.x = Math.sin(t * 0.08) * 0.5 + smooth.current.x * 2.5;
|
||||
camera.position.y = Math.cos(t * 0.06) * 0.3 + smooth.current.y * 1.5;
|
||||
camera.position.z = 9 + Math.sin(t * 0.1) * 0.3;
|
||||
camera.lookAt(smooth.current.x * 0.5, smooth.current.y * 0.3, 0);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Exported component ---------- */
|
||||
export default function AtomGlitch() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
}}
|
||||
aria-label="Atome 3D animé — L'Electron Rare"
|
||||
role="img"
|
||||
>
|
||||
<Canvas
|
||||
camera={{ position: [0, -0.5, 9], fov: 45 }}
|
||||
dpr={[1, 2]}
|
||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<color attach="background" args={['#000000']} />
|
||||
<fog attach="fog" args={['#000000', 8, 18]} />
|
||||
|
||||
<ambientLight intensity={0.15} />
|
||||
<pointLight position={[5, 3, 5]} intensity={0.8} color="#ffffff" />
|
||||
<pointLight position={[-4, -2, 3]} intensity={0.4} color="#5bd1d8" />
|
||||
<pointLight position={[0, 4, -3]} intensity={0.3} color="#f1c27a" />
|
||||
|
||||
<CameraRig />
|
||||
<AtomScene />
|
||||
|
||||
<EffectComposer>
|
||||
<Bloom luminanceThreshold={0.2} luminanceSmoothing={0.9} intensity={1.8} mipmapBlur />
|
||||
<ChromaticAberration blendFunction={BlendFunction.NORMAL} offset={new THREE.Vector2(0.002, 0.002)} />
|
||||
<GlitchEffect delay={new THREE.Vector2(3, 8)} duration={new THREE.Vector2(0.1, 0.4)} strength={new THREE.Vector2(0.05, 0.15)} mode={GlitchMode.SPORADIC} />
|
||||
<Scanline blendFunction={BlendFunction.OVERLAY} density={1.8} opacity={0.05} />
|
||||
</EffectComposer>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+120
-190
@@ -1,63 +1,42 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import AtomGlitch from '@/components/AtomGlitch';
|
||||
import '@/styles/global.css';
|
||||
---
|
||||
|
||||
<BaseLayout title="L'Electron Rare — Bientôt" description="L'Electron Rare arrive. Systèmes électroniques spécifiques, formations, prototypage.">
|
||||
<main class="countdown-page">
|
||||
<div class="logo-sprite" id="logo-sprite" aria-label="L'Electron Rare — logo animé"></div>
|
||||
|
||||
<svg class="atom" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-cyan-soft, #5bd1d866)" stroke-width="0.8" transform="rotate(-30 150 150)"/>
|
||||
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-amber-soft, #f1c27a66)" stroke-width="0.8" transform="rotate(30 150 150)"/>
|
||||
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green-soft, #b6d18f44)" stroke-width="0.8" transform="rotate(90 150 150)"/>
|
||||
<circle cx="150" cy="150" r="18" fill="url(#nucleusGlow)"/>
|
||||
<circle cx="150" cy="150" r="8" fill="var(--accent, #f1c27a)"/>
|
||||
<circle r="5" fill="var(--electric, #5bd1d8)"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion></circle>
|
||||
<circle r="10" fill="var(--electric, #5bd1d8)" opacity="0.3"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion></circle>
|
||||
<circle r="4" fill="var(--accent, #f1c27a)"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion></circle>
|
||||
<circle r="9" fill="var(--accent, #f1c27a)" opacity="0.25"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion></circle>
|
||||
<circle r="3" fill="var(--trace-green, #b6d18f)"><animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion></circle>
|
||||
<circle r="8" fill="var(--trace-green, #b6d18f)" opacity="0.2"><animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion></circle>
|
||||
<defs>
|
||||
<ellipse id="o1" cx="150" cy="150" rx="120" ry="45" transform="rotate(-30 150 150)" fill="none"/>
|
||||
<ellipse id="o2" cx="150" cy="150" rx="120" ry="45" transform="rotate(30 150 150)" fill="none"/>
|
||||
<ellipse id="o3" cx="150" cy="150" rx="120" ry="45" transform="rotate(90 150 150)" fill="none"/>
|
||||
<radialGradient id="nucleusGlow">
|
||||
<stop offset="0%" stop-color="var(--accent, #f1c27a)" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="var(--accent, #f1c27a)" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="title-bubble">
|
||||
<h1 class="title" data-text="L'électron rare">L'<span class="electric">é</span>lectron rare</h1>
|
||||
<!-- WebGL background -->
|
||||
<div class="webgl-bg">
|
||||
<AtomGlitch client:load />
|
||||
</div>
|
||||
|
||||
<div class="timer-bubble">
|
||||
<div class="timer" id="timer">
|
||||
<div class="unit"><span class="num" id="days">--</span><span class="label">jours</span></div>
|
||||
<div class="sep">:</div>
|
||||
<div class="unit"><span class="num" id="hours">--</span><span class="label">heures</span></div>
|
||||
<div class="sep">:</div>
|
||||
<div class="unit"><span class="num" id="minutes">--</span><span class="label">minutes</span></div>
|
||||
<div class="sep">:</div>
|
||||
<div class="unit"><span class="num" id="seconds">--</span><span class="label">secondes</span></div>
|
||||
<!-- Overlay content -->
|
||||
<div class="overlay">
|
||||
<!-- Top bar -->
|
||||
<div class="top-bar">
|
||||
<div class="logo-sprite" id="logo-sprite" aria-label="L'Electron Rare — logo animé"></div>
|
||||
<span class="brand-mark">L'ELECTRON RARE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tagline-bubble" id="tagline-bubble" aria-live="polite">
|
||||
<span class="tagline-text" id="tagline"></span>
|
||||
</div>
|
||||
<!-- Center — spacer (text is in WebGL) -->
|
||||
<div class="center-spacer">
|
||||
<div class="tagline-area" id="tagline-bubble" aria-live="polite">
|
||||
<span class="tagline-cursor">></span>
|
||||
<span class="tagline-text" id="tagline"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footnote-bubble">
|
||||
<span>Systèmes électroniques spécifiques · Formations · Prototypage</span>
|
||||
<!-- Bottom bar -->
|
||||
<div class="bottom-bar">
|
||||
<span class="bottom-left">electronique · automatisme · energie</span>
|
||||
<span class="bottom-right">mai 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const LAUNCH = new Date('2026-05-01T00:00:00+02:00').getTime();
|
||||
const messages = [
|
||||
"Charge des condensateurs en cours...",
|
||||
"Calibration de l'oscilloscope...",
|
||||
@@ -77,17 +56,9 @@ import '@/styles/global.css';
|
||||
];
|
||||
let msgIndex = Math.floor(Math.random() * messages.length);
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function update() {
|
||||
const diff = Math.max(0, LAUNCH - Date.now());
|
||||
document.getElementById('days').textContent = pad(Math.floor(diff / 86400000));
|
||||
document.getElementById('hours').textContent = pad(Math.floor((diff % 86400000) / 3600000));
|
||||
document.getElementById('minutes').textContent = pad(Math.floor((diff % 3600000) / 60000));
|
||||
document.getElementById('seconds').textContent = pad(Math.floor((diff % 60000) / 1000));
|
||||
}
|
||||
function rotateMessage() {
|
||||
const bubble = document.getElementById('tagline-bubble');
|
||||
const el = document.getElementById('tagline');
|
||||
const bubble = document.getElementById('tagline-bubble')!;
|
||||
const el = document.getElementById('tagline')!;
|
||||
bubble.style.opacity = '0';
|
||||
bubble.style.transform = 'translateY(4px)';
|
||||
setTimeout(() => {
|
||||
@@ -98,180 +69,139 @@ import '@/styles/global.css';
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Logo sprite animation - cycle through 4 frames
|
||||
const frames = [
|
||||
'/assets/brand/logo-frame-1.png',
|
||||
'/assets/brand/logo-frame-2.png',
|
||||
'/assets/brand/logo-frame-3.png',
|
||||
'/assets/brand/logo-frame-4.png',
|
||||
'/assets/brand/logo-frame-3.png',
|
||||
'/assets/brand/logo-frame-2.png',
|
||||
];
|
||||
let frameIdx = 0;
|
||||
const sprite = document.getElementById('logo-sprite');
|
||||
const sprite = document.getElementById('logo-sprite')!;
|
||||
sprite.style.backgroundImage = `url(/assets/brand/logo-mark.png)`;
|
||||
|
||||
// Preload frames
|
||||
frames.forEach(f => { const img = new Image(); img.src = f; });
|
||||
|
||||
function animateLogo() {
|
||||
sprite.style.opacity = '0.85';
|
||||
setTimeout(() => {
|
||||
sprite.style.backgroundImage = `url(${frames[frameIdx]})`;
|
||||
sprite.style.opacity = '1';
|
||||
frameIdx = (frameIdx + 1) % frames.length;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
update(); rotateMessage();
|
||||
sprite.style.backgroundImage = `url(${frames[0]})`;
|
||||
setInterval(update, 1000);
|
||||
rotateMessage();
|
||||
setInterval(rotateMessage, 8000);
|
||||
setInterval(animateLogo, 2000);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.countdown-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', 'Helvetica Neue', sans-serif;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* WebGL canvas — full background */
|
||||
.webgl-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Overlay — full viewport, vertical layout */
|
||||
.overlay {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: clamp(32px, 6vw, 80px) clamp(20px, 4vw, 48px);
|
||||
background-color: #ffffff;
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', 'Helvetica Neue', sans-serif;
|
||||
justify-content: space-between;
|
||||
padding: clamp(20px, 3vw, 40px) clamp(24px, 5vw, 60px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.overlay > * { pointer-events: auto; }
|
||||
|
||||
/* Logo — clean, static display */
|
||||
/* ===== TOP BAR ===== */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
animation: fade-in 0.8s ease both;
|
||||
}
|
||||
.logo-sprite {
|
||||
width: clamp(120px, 18vw, 180px);
|
||||
height: clamp(120px, 18vw, 180px);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
margin-bottom: clamp(16px, 3vw, 32px);
|
||||
transition: opacity 0.3s ease;
|
||||
animation: gentle-float 6s ease-in-out infinite;
|
||||
filter: brightness(1.2) drop-shadow(0 0 12px rgba(91, 209, 216, 0.4));
|
||||
}
|
||||
@keyframes gentle-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
.brand-mark {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.25em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.atom {
|
||||
width: clamp(100px, 16vw, 150px);
|
||||
height: auto;
|
||||
margin-bottom: clamp(16px, 2vw, 24px);
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
/* Title bubble */
|
||||
.title-bubble {
|
||||
margin-bottom: clamp(24px, 4vw, 40px);
|
||||
padding: 0;
|
||||
animation: fade-in 0.8s ease both;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
|
||||
font-size: clamp(32px, 6vw, 56px);
|
||||
font-weight: 800;
|
||||
color: #1d1d1f;
|
||||
margin: 0;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.electric {
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
/* Timer */
|
||||
.timer-bubble {
|
||||
background: #f5f5f7;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: clamp(16px, 3vw, 28px) clamp(24px, 4vw, 48px);
|
||||
margin-bottom: clamp(20px, 3vw, 32px);
|
||||
animation: fade-in 0.8s 0.2s ease both;
|
||||
}
|
||||
.timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(8px, 2vw, 16px);
|
||||
}
|
||||
.unit {
|
||||
/* ===== CENTER SPACER ===== */
|
||||
.center-spacer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.num {
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
|
||||
font-size: clamp(36px, 8vw, 72px);
|
||||
font-weight: 300;
|
||||
color: #1d1d1f;
|
||||
line-height: 1;
|
||||
min-width: 2ch;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.label {
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
|
||||
font-size: clamp(9px, 1.2vw, 12px);
|
||||
color: #86868b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sep {
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
|
||||
font-size: clamp(24px, 5vw, 48px);
|
||||
color: #d2d2d7;
|
||||
font-weight: 200;
|
||||
margin-bottom: 18px;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: clamp(20px, 3vw, 40px);
|
||||
}
|
||||
|
||||
/* Tagline */
|
||||
.tagline-bubble {
|
||||
padding: 12px 24px;
|
||||
margin-bottom: clamp(16px, 2vw, 24px);
|
||||
min-height: 44px;
|
||||
/* Tagline — terminal style */
|
||||
.tagline-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 10px 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
animation: fade-in 0.8s 0.4s ease both;
|
||||
animation: fade-in 0.8s 1.1s ease both;
|
||||
}
|
||||
.tagline-cursor {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
color: #5bd1d8;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.tagline-text {
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
|
||||
font-size: clamp(13px, 1.6vw, 16px);
|
||||
color: #86868b;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Footnote */
|
||||
.footnote-bubble {
|
||||
padding: clamp(8px, 1.5vw, 12px) 20px;
|
||||
animation: fade-in 0.8s 0.6s ease both;
|
||||
}
|
||||
.footnote-bubble span {
|
||||
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
|
||||
font-size: clamp(11px, 1.2vw, 13px);
|
||||
color: #aeaeb2;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: clamp(11px, 1.3vw, 14px);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
/* ===== BOTTOM BAR ===== */
|
||||
.bottom-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
animation: fade-in 0.8s 1.2s ease both;
|
||||
}
|
||||
.bottom-left, .bottom-right {
|
||||
font-size: clamp(10px, 1.1vw, 12px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ===== Animations ===== */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.timer-hero { gap: 2px; }
|
||||
.sep { margin: 0; }
|
||||
.bottom-bar { flex-direction: column; align-items: center; gap: 4px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logo-sprite { animation: none; }
|
||||
.title-bubble, .timer-bubble, .tagline-bubble, .footnote-bubble {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.logo-sprite, .title, .timer-hero, .divider,
|
||||
.tagline-area, .subtitle, .overline,
|
||||
.top-bar, .bottom-bar { animation: none; opacity: 1; }
|
||||
.webgl-bg { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user