refactor: clean slate — remove all WebGL, keep static + loading screen

REMOVED:
- AtomGlitch.tsx (countdown 3D atom)
- WebGLBackground.tsx (PCB 3D scene)
- WebGLCards.tsx (scroll animations)
- webgl-cards.css
- All GLB models (models3d/)
- Orbitron + Space Mono fonts

KEPT:
- static.astro (accessible version, no JS)
- Loading screen with BMU photo on preview
- Dark site CSS tokens in global.css
- bmu-assembly.jpg photo

SIMPLIFIED:
- index.astro: countdown without WebGL (placeholder div ready)
- preview.astro: loading screen + sections, WebGL placeholder ready
- Both pages have empty #webgl-* divs for new WebGL implementation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clément SAILLANT
2026-03-30 18:04:19 +00:00
parent adb756272b
commit e49fdea8f0
15 changed files with 111 additions and 2808 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-640
View File
@@ -1,640 +0,0 @@
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);
}
});
// Logo refs — 3 copies at different rotations
const logo1Ref = useRef<THREE.Group>(null);
const logo2Ref = useRef<THREE.Group>(null);
const logo3Ref = useRef<THREE.Group>(null);
useFrame(({ clock, camera }) => {
const t = clock.getElapsedTime();
// Each logo copy rotates on a different axis
if (logo1Ref.current) {
logo1Ref.current.rotation.y = t * 0.8;
logo1Ref.current.quaternion.copy(camera.quaternion);
}
if (logo2Ref.current) {
logo2Ref.current.rotation.set(t * 0.5, t * 0.3, 0);
}
if (logo3Ref.current) {
logo3Ref.current.rotation.set(0, t * -0.4, t * 0.6);
}
});
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>
{/* ER logo — 3 copies at center, different rotations */}
<group ref={logo1Ref}>
<Text fontSize={0.25} font={FONT_URL} anchorX="center" anchorY="middle" letterSpacing={0.05}>
ER
<meshStandardMaterial color="#ffffff" emissive="#5bd1d8" emissiveIntensity={1.5} transparent opacity={0.7} />
</Text>
</group>
<group ref={logo2Ref}>
<Text fontSize={0.2} font={FONT_URL} anchorX="center" anchorY="middle" letterSpacing={0.05}>
ER
<meshStandardMaterial color="#ffffff" emissive="#f1c27a" emissiveIntensity={1} transparent opacity={0.35} />
</Text>
</group>
<group ref={logo3Ref}>
<Text fontSize={0.18} font={FONT_URL} anchorX="center" anchorY="middle" letterSpacing={0.05}>
ER
<meshStandardMaterial color="#ffffff" emissive="#b6d18f" emissiveIntensity={0.8} transparent opacity={0.25} />
</Text>
</group>
</group>
);
}
/* ---------- ER logo (PNG) orbiting close to nucleus ---------- */
function LogoOrbit({ tilt, speed, radius, color }: {
tilt: number[]; speed: number; radius: number; color: string;
}) {
const ref = useRef<THREE.Group>(null);
const planeRef = useRef<THREE.Group>(null);
const [texture, setTexture] = useState<THREE.Texture | null>(null);
const phase = useMemo(() => Math.random() * Math.PI * 2, []);
const eccentricity = 0.35;
const threeColor = useMemo(() => new THREE.Color(color), [color]);
useEffect(() => {
new THREE.TextureLoader().load('/assets/brand/logo-mark.png', (tex) => {
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
setTexture(tex);
});
}, []);
useFrame(({ clock, camera }) => {
if (!ref.current || !planeRef.current) return;
const t = clock.getElapsedTime() * speed + phase;
ref.current.position.set(
Math.cos(t) * radius,
Math.sin(t) * radius * eccentricity,
0,
);
// Billboard — always face camera
planeRef.current.quaternion.copy(camera.quaternion);
});
if (!texture) return null;
return (
<group rotation={tilt as [number, number, number]}>
<group ref={ref}>
<group ref={planeRef}>
{/* Logo PNG on a plane */}
<mesh>
<planeGeometry args={[0.35, 0.35]} />
<meshStandardMaterial
map={texture}
transparent
opacity={0.85}
emissive={threeColor}
emissiveIntensity={0.8}
side={THREE.DoubleSide}
alphaTest={0.1}
/>
</mesh>
</group>
{/* Glow sphere behind logo */}
<mesh>
<sphereGeometry args={[0.15, 8, 8]} />
<meshStandardMaterial
color={color}
emissive={threeColor}
emissiveIntensity={1.2}
transparent
opacity={0.15}
/>
</mesh>
</group>
</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/orbitron-bold.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 = 2.5;
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" — tight elliptical orbit, close to nucleus */}
<CircularText
text={"L'\u00e9lectron"}
radius={2.0}
speed={0.18}
tilt={[-0.4, 0, 0]}
fontSize={0.45}
color="#ffffff"
emissive="#ffffff"
spread={1.2}
/>
{/* "rare" — opposite tilt, close */}
<CircularText
text="rare"
radius={1.8}
speed={-0.22}
tilt={[0.6, 0.3, 0]}
fontSize={0.7}
color="#ffffff"
emissive="#5bd1d8"
spread={1.3}
/>
{/* Subtitle — medium ring */}
<CircularText
text="SYSTEMES ELECTRONIQUES SPECIFIQUES"
radius={3.0}
speed={0.1}
tilt={[1.2, 0.2, 0.3]}
fontSize={0.1}
color="#ffffff"
emissive="#5bd1d8"
opacity={0.35}
spread={0.6}
/>
{/* "LANCEMENT DANS" — small tight ring */}
<CircularText
text="LANCEMENT DANS"
radius={1.5}
speed={0.3}
tilt={[-0.8, -0.2, 0.15]}
fontSize={0.09}
color="#5bd1d8"
emissive="#5bd1d8"
opacity={0.5}
spread={0.8}
/>
{/* 3x ER logo orbiting close to nucleus like inner electrons */}
<LogoOrbit tilt={[-0.3, 0, 0.1]} speed={0.6} radius={1.2} color="#5bd1d8" />
<LogoOrbit tilt={[0.5, 0.2, -0.1]} speed={-0.45} radius={1.0} color="#f1c27a" />
<LogoOrbit tilt={[1.2, -0.3, 0.3]} speed={0.35} radius={1.3} color="#b6d18f" />
{/* Countdown timer — spinning ring */}
<CountdownRing />
{/* Extra decorative text rings */}
<CircularText
text="electronique automatisme energie stockage prototypage formation"
radius={3.5}
speed={-0.06}
tilt={[0.8, -0.4, 0.5]}
fontSize={0.06}
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>
);
}
-513
View File
@@ -1,513 +0,0 @@
import { useRef, useMemo, useEffect, useState, Suspense } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Text, useGLTF } from '@react-three/drei';
import { EffectComposer, Bloom, ChromaticAberration, Scanline } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
import * as THREE from 'three';
/* ===================================================================
PCB JOURNEY v2 — Real KiCad PCB + real component GLBs
The BMU v2 PCB is loaded as the central 3D object.
Camera orbits around it, zooming into component zones on scroll.
Real GLB components from KiCad library placed on the board.
Current flows along the copper traces.
=================================================================== */
const COLORS = {
copper: new THREE.Color('#c87533'),
copperBright: new THREE.Color('#e8a040'),
current: new THREE.Color('#5bd1d8'),
currentWarm: new THREE.Color('#f1c27a'),
silkscreen: new THREE.Color('#e8e8d0'),
};
const FONT_URL = '/assets/fonts/orbitron-bold.ttf';
/* ---------- Scroll state ---------- */
let scrollProgress = 0;
/* ---------- Real PCB Board (full 3D with components from FreeCAD) ---------- */
function RealPCB() {
let glb: any = null;
try { glb = useGLTF('/assets/models3d/bmu-assembly.glb'); } catch {}
if (!glb?.scene) return null;
// Deep clone with materials preserved
const cloned = useMemo(() => {
const clone = glb.scene.clone(true);
clone.traverse((child: any) => {
if (child.isMesh && child.material) {
child.material = child.material.clone();
}
});
return clone;
}, [glb.scene]);
return (
<primitive
object={cloned}
scale={1}
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, 0]}
/>
);
}
/* ---------- GLB Component placement ---------- */
function GLBComponent({ modelUrl, position, scale = 1, rotation = [0, 0, 0] as [number, number, number] }: {
modelUrl: string; position: [number, number, number]; scale?: number; rotation?: [number, number, number];
}) {
let glb: any = null;
try { glb = useGLTF(modelUrl); } catch {}
if (!glb?.scene) return null;
return (
<primitive
object={glb.scene.clone()}
position={position}
scale={scale}
rotation={rotation}
/>
);
}
/* ---------- Section label (silkscreen on board, clickable) ---------- */
function SectionLabel({ position, text, subtitle, scrollTarget }: {
position: [number, number, number]; text: string; subtitle?: string; scrollTarget?: string;
}) {
const ref = useRef<THREE.Group>(null);
const [hovered, setHovered] = useState(false);
useFrame(({ camera }) => {
if (!ref.current) return;
const worldPos = new THREE.Vector3();
ref.current.getWorldPosition(worldPos);
const dist = camera.position.distanceTo(worldPos);
const glow = Math.max(0, 1 - dist / 12);
// Dynamic opacity based on camera distance
ref.current.children.forEach(child => {
if ((child as any).material) {
(child as any).material.opacity = hovered ? 0.9 : 0.3 + glow * 0.4;
}
});
});
const handleClick = () => {
if (!scrollTarget) return;
if (scrollTarget === '#contact') {
// Open contact popup
document.getElementById('contact')?.classList.add('contact-3d-popup--open');
} else {
const el = document.querySelector(scrollTarget);
if (el) el.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<group
ref={ref}
position={position}
onClick={handleClick}
onPointerOver={() => { setHovered(true); document.body.style.cursor = scrollTarget ? 'pointer' : 'default'; }}
onPointerOut={() => { setHovered(false); document.body.style.cursor = 'default'; }}
>
{/* Clickable hit area (invisible plane) */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0.4]}>
<planeGeometry args={[text.length * 0.55, 2.5]} />
<meshBasicMaterial transparent opacity={0} side={THREE.DoubleSide} />
</mesh>
{/* Main label */}
<Text
rotation={[-Math.PI / 2, 0, 0]}
fontSize={hovered ? 0.9 : 0.8}
font={FONT_URL}
color={hovered ? '#ffffff' : '#5bd1d8'}
anchorX="center"
anchorY="middle"
material-transparent
material-opacity={0.4}
letterSpacing={0.08}
>
{text}
<meshStandardMaterial
color={hovered ? '#ffffff' : '#5bd1d8'}
emissive={hovered ? '#5bd1d8' : '#5bd1d8'}
emissiveIntensity={hovered ? 1.5 : 0.3}
transparent
opacity={0.4}
side={THREE.DoubleSide}
/>
</Text>
{/* Subtitle */}
{subtitle && (
<Text
position={[0, 0, 1.2]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={0.3}
font={FONT_URL}
color="#e8e8d0"
anchorX="center"
anchorY="middle"
material-transparent
material-opacity={hovered ? 0.5 : 0.2}
letterSpacing={0.04}
>
{subtitle}
</Text>
)}
{/* Hover glow ring */}
{hovered && (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.02, 0.4]}>
<ringGeometry args={[text.length * 0.25, text.length * 0.28, 32]} />
<meshStandardMaterial color="#5bd1d8" emissive="#5bd1d8" emissiveIntensity={2} transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh>
)}
</group>
);
}
/* ---------- Current flow particles ---------- */
function CurrentFlow({ count = 80 }) {
const ref = useRef<THREE.InstancedMesh>(null);
const dummy = useMemo(() => new THREE.Object3D(), []);
// Particles follow a circular path around the PCB
const phases = useMemo(() => Array.from({ length: count }, () => Math.random()), [count]);
const radii = useMemo(() => Array.from({ length: count }, () => 3 + Math.random() * 8), [count]);
const heights = useMemo(() => Array.from({ length: count }, () => 0.3 + Math.random() * 0.5), [count]);
useFrame(({ clock }) => {
if (!ref.current) return;
const t = clock.getElapsedTime();
for (let i = 0; i < count; i++) {
const angle = phases[i] * Math.PI * 2 + t * (0.1 + phases[i] * 0.1);
const r = radii[i];
dummy.position.set(
Math.cos(angle) * r,
heights[i] + Math.sin(t * 2 + i) * 0.1,
Math.sin(angle) * r * 0.6,
);
const proximity = Math.max(0.2, 1 - Math.abs(scrollProgress - phases[i]) * 3);
dummy.scale.setScalar(0.04 * proximity);
dummy.updateMatrix();
ref.current.setMatrixAt(i, dummy.matrix);
}
ref.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={ref} args={[undefined, undefined, count]}>
<sphereGeometry args={[1, 6, 6]} />
<meshStandardMaterial color={COLORS.current} emissive={COLORS.current} emissiveIntensity={3} transparent opacity={0.8} />
</instancedMesh>
);
}
/* ---------- Warm current (second layer) ---------- */
function WarmCurrent({ count = 40 }) {
const ref = useRef<THREE.InstancedMesh>(null);
const dummy = useMemo(() => new THREE.Object3D(), []);
const phases = useMemo(() => Array.from({ length: count }, () => Math.random()), [count]);
useFrame(({ clock }) => {
if (!ref.current) return;
const t = clock.getElapsedTime();
for (let i = 0; i < count; i++) {
const angle = phases[i] * Math.PI * 2 + t * 0.06 + Math.PI;
const r = 4 + phases[i] * 6;
dummy.position.set(
Math.cos(angle) * r,
0.2 + Math.sin(t + i) * 0.05,
Math.sin(angle) * r * 0.5,
);
dummy.scale.setScalar(0.025);
dummy.updateMatrix();
ref.current.setMatrixAt(i, dummy.matrix);
}
ref.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={ref} args={[undefined, undefined, count]}>
<sphereGeometry args={[1, 6, 6]} />
<meshStandardMaterial color={COLORS.currentWarm} emissive={COLORS.currentWarm} emissiveIntensity={2} transparent opacity={0.6} />
</instancedMesh>
);
}
/* ---------- Ambient dust ---------- */
function AmbientDust({ count = 150 }) {
const ref = useRef<THREE.Points>(null);
const positions = useMemo(() => {
const pos = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
pos[i * 3] = (Math.random() - 0.5) * 30;
pos[i * 3 + 1] = Math.random() * 4;
pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
}
return pos;
}, [count]);
useFrame(({ clock }) => {
if (!ref.current) return;
const arr = (ref.current.geometry.attributes.position as THREE.BufferAttribute).array as Float32Array;
const t = clock.getElapsedTime();
for (let i = 0; i < count; i++) {
arr[i * 3 + 1] += Math.sin(t * 0.3 + i * 0.5) * 0.001;
}
ref.current.geometry.attributes.position.needsUpdate = true;
});
return (
<points ref={ref}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial size={0.04} color={COLORS.current} transparent opacity={0.15} sizeAttenuation depthWrite={false} blending={THREE.AdditiveBlending} />
</points>
);
}
/* ---------- Component clusters around the PCB ---------- */
function ComponentCluster() {
const R = [-Math.PI / 2, 0, 0] as [number, number, number];
const S_RES = 8;
const S_CAP = 10;
const S_LED = 8;
const S_IC_SM = 6;
const S_IC_LG = 3;
return (
<group>
{/* Zone 1 — MCU area (top-right of board) */}
<SectionLabel position={[40, 90, -10]} text="MCU" subtitle="Approche & expertise" scrollTarget="#a-propos" />
<GLBComponent modelUrl="/assets/models3d/qfp32.glb" position={[5, 0.3, -2]} scale={S_IC_LG} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/soic8.glb" position={[3, 0.3, -1]} scale={S_IC_SM} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[4, 0.3, 0]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[4.5, 0.3, 0.5]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[6.5, 0.3, -0.5]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[7, 0.3, -3]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/led_0603.glb" position={[7.5, 0.3, -1]} scale={S_LED} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/led_0603.glb" position={[7.5, 0.3, -2.5]} scale={S_LED} rotation={R} />
{/* Zone 2 — Analog / Op-amp area (left of board) */}
<SectionLabel position={[-30, 90, -10]} text="ANALOG" subtitle="Cas concrets" scrollTarget="#cas-concrets" />
<GLBComponent modelUrl="/assets/models3d/soic8.glb" position={[-5, 0.3, -1]} scale={S_IC_SM} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/soic8.glb" position={[-3, 0.3, -2]} scale={S_IC_SM} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[-4, 0.3, 0]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[-6, 0.3, -2]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[-4.5, 0.3, -3]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[-6.5, 0.3, 0]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/inductor_0805.glb" position={[-3.5, 0.3, 1]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/led_0603.glb" position={[-2, 0.3, -1]} scale={S_LED} rotation={R} />
{/* Zone 3 — Power stage (bottom-right) */}
<SectionLabel position={[40, 30, -10]} text="POWER" subtitle="Photos & videos terrain" scrollTarget="#photos" />
<GLBComponent modelUrl="/assets/models3d/qfp32.glb" position={[4, 0.3, 4]} scale={S_IC_LG} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[2, 0.3, 3]} scale={S_CAP * 1.5} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[6, 0.3, 5]} scale={S_CAP * 1.5} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[3, 0.3, 5.5]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/inductor_0805.glb" position={[5, 0.3, 3]} scale={S_CAP * 1.2} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[5.5, 0.3, 6]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/led_0603.glb" position={[7, 0.3, 4.5]} scale={S_LED} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/led_0603.glb" position={[1.5, 0.3, 4.5]} scale={S_LED} rotation={R} />
{/* Zone 4 — Communication (bottom-left) */}
<SectionLabel position={[-30, 30, -10]} text="FORMATION" subtitle="Workshops & programmes" scrollTarget="/formation/" />
<GLBComponent modelUrl="/assets/models3d/soic8.glb" position={[-5, 0.3, 4]} scale={S_IC_SM} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/soic8.glb" position={[-3, 0.3, 5]} scale={S_IC_SM} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[-6, 0.3, 3]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[-4, 0.3, 6]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[-6.5, 0.3, 5]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/led_0603.glb" position={[-2, 0.3, 3.5]} scale={S_LED} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/led_0603.glb" position={[-7, 0.3, 4.5]} scale={S_LED} rotation={R} />
{/* Zone 5 — DSP / Neural (center-top) */}
<SectionLabel position={[4, 110, -10]} text="MISSIONS" subtitle="Diagnostic · Prototype · Production" scrollTarget="#graphic-sprints-title" />
<GLBComponent modelUrl="/assets/models3d/qfp32.glb" position={[0, 0.3, -4]} scale={S_IC_LG} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[-2, 0.3, -4.5]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/capacitor_0805.glb" position={[2, 0.3, -3.5]} scale={S_CAP} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[-1, 0.3, -5]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/resistor_0603.glb" position={[1, 0.3, -5.5]} scale={S_RES} rotation={R} />
<GLBComponent modelUrl="/assets/models3d/inductor_0805.glb" position={[0, 0.3, -6]} scale={S_CAP} rotation={R} />
{/* Zone 6 — Contact area (center) */}
<SectionLabel position={[4, 66, -10]} text="CONTACT" subtitle="Discuter de votre projet" scrollTarget="#contact" />
{/* Scattered passives */}
{Array.from({ length: 15 }).map((_, i) => (
<GLBComponent
key={`scat-r-${i}`}
modelUrl="/assets/models3d/resistor_0603.glb"
position={[(Math.random() - 0.5) * 16, 0.3, (Math.random() - 0.5) * 14]}
scale={S_RES}
rotation={[-Math.PI / 2, 0, Math.random() * Math.PI]}
/>
))}
{Array.from({ length: 10 }).map((_, i) => (
<GLBComponent
key={`scat-c-${i}`}
modelUrl="/assets/models3d/capacitor_0805.glb"
position={[(Math.random() - 0.5) * 16, 0.3, (Math.random() - 0.5) * 14]}
scale={S_CAP}
rotation={R}
/>
))}
</group>
);
}
/* ---------- Board title silkscreen ---------- */
/* ---------- Light that follows the camera ---------- */
function CameraLight() {
const lightRef = useRef<THREE.PointLight>(null);
const { camera } = useThree();
useFrame(() => {
if (!lightRef.current) return;
lightRef.current.position.copy(camera.position);
lightRef.current.position.y += 0.03;
});
return (
<pointLight ref={lightRef} intensity={0.6} color="#ffffff" distance={0.5} decay={1.5} />
);
}
/* ---------- Camera — orbits around PCB, zooms on scroll ---------- */
// STEP units = METERS. Board ~0.17m wide, ~0.10m tall. After -π/2 X rotation:
// model Y→scene -Z, model Z→scene Y. Board center ≈ (0, 0.04, 0).
const CAMERA_STOPS = [
{ scroll: 0.00, pos: [0, 0.3, 0.2], look: [0, 0.04, 0] }, // Overview
{ scroll: 0.12, pos: [0.06, 0.12, 0.02], look: [0.04, 0.04, 0] }, // MCU (right)
{ scroll: 0.28, pos: [-0.06, 0.12, 0.02], look: [-0.04, 0.04, 0] }, // Analog (left)
{ scroll: 0.42, pos: [0.05, 0.08, -0.03], look: [0.03, 0.04, -0.02] }, // Power
{ scroll: 0.56, pos: [-0.05, 0.08, -0.03], look: [-0.03, 0.04, -0.02] }, // Formation
{ scroll: 0.70, pos: [0, 0.15, 0.05], look: [0, 0.06, 0] }, // Missions
{ scroll: 0.85, pos: [0, 0.08, 0.01], look: [0, 0.04, 0] }, // Contact (close)
{ scroll: 1.00, pos: [0, 0.4, 0.25], look: [0, 0.04, 0] }, // Pull back
];
function OrbitCamera() {
const { camera } = useThree();
const smooth = useRef({ x: 0, y: 0, z: 0, lx: 0, ly: 0, lz: 0 });
useFrame(({ pointer }) => {
const s = scrollProgress;
let a = CAMERA_STOPS[0], b = CAMERA_STOPS[1];
for (let i = 0; i < CAMERA_STOPS.length - 1; i++) {
if (s >= CAMERA_STOPS[i].scroll && s <= CAMERA_STOPS[i + 1].scroll) {
a = CAMERA_STOPS[i];
b = CAMERA_STOPS[i + 1];
break;
}
}
const range = b.scroll - a.scroll;
const t = range > 0 ? (s - a.scroll) / range : 0;
const ease = t * t * (3 - 2 * t);
const tx = a.pos[0] + (b.pos[0] - a.pos[0]) * ease + pointer.x * 0.03;
const ty = a.pos[1] + (b.pos[1] - a.pos[1]) * ease + pointer.y * 0.01;
const tz = a.pos[2] + (b.pos[2] - a.pos[2]) * ease;
const lx = a.look[0] + (b.look[0] - a.look[0]) * ease + pointer.x * 0.005;
const ly = a.look[1] + (b.look[1] - a.look[1]) * ease;
const lz = a.look[2] + (b.look[2] - a.look[2]) * ease;
smooth.current.x += (tx - smooth.current.x) * 0.05;
smooth.current.y += (ty - smooth.current.y) * 0.05;
smooth.current.z += (tz - smooth.current.z) * 0.05;
smooth.current.lx += (lx - smooth.current.lx) * 0.05;
smooth.current.ly += (ly - smooth.current.ly) * 0.05;
smooth.current.lz += (lz - smooth.current.lz) * 0.05;
camera.position.set(smooth.current.x, smooth.current.y, smooth.current.z);
camera.lookAt(smooth.current.lx, smooth.current.ly, smooth.current.lz);
});
return null;
}
/* ---------- Full scene ---------- */
function PCBScene() {
return (
<group>
<Suspense fallback={null}>
<RealPCB />
<ComponentCluster />
</Suspense>
<CurrentFlow />
<WarmCurrent />
<AmbientDust />
</group>
);
}
/* ---------- Exported component ---------- */
export default function WebGLBackground() {
useEffect(() => {
const onScroll = () => {
const el = document.documentElement;
const max = Math.max(1, el.scrollHeight - el.clientHeight);
scrollProgress = el.scrollTop / max;
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
style={{
position: 'fixed',
inset: 0,
width: '100vw',
height: '100vh',
zIndex: 0,
pointerEvents: 'none',
}}
aria-hidden="true"
>
<Canvas
camera={{ position: [0, 0.3, 0.2], fov: 50 }}
dpr={[1, 1.5]}
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
style={{ pointerEvents: 'auto' }}
>
<color attach="background" args={['#060a06']} />
<fog attach="fog" args={['#060a06', 0.2, 0.8]} />
<ambientLight intensity={0.25} />
<directionalLight position={[0.1, 0.2, 0.1]} intensity={0.7} color="#ffffff" />
<directionalLight position={[-0.1, 0.15, -0.1]} intensity={0.3} color="#5bd1d8" />
<pointLight position={[0, 0.1, 0]} intensity={0.4} color="#f1c27a" distance={0.5} />
<CameraLight />
<OrbitCamera />
<PCBScene />
<EffectComposer>
<Bloom luminanceThreshold={0.4} luminanceSmoothing={0.9} intensity={0.7} mipmapBlur />
<ChromaticAberration blendFunction={BlendFunction.NORMAL} offset={new THREE.Vector2(0.0006, 0.0006)} />
<Scanline blendFunction={BlendFunction.OVERLAY} density={1.5} opacity={0.03} />
</EffectComposer>
</Canvas>
</div>
);
}
/* Preload all models */
useGLTF.preload('/assets/models3d/bmu-assembly.glb');
useGLTF.preload('/assets/models3d/resistor_0603.glb');
useGLTF.preload('/assets/models3d/capacitor_0805.glb');
useGLTF.preload('/assets/models3d/inductor_0805.glb');
useGLTF.preload('/assets/models3d/led_0603.glb');
useGLTF.preload('/assets/models3d/soic8.glb');
useGLTF.preload('/assets/models3d/qfp32.glb');
-203
View File
@@ -1,203 +0,0 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import * as THREE from 'three';
/* ===================================================================
WEBGL CARDS — Scroll-triggered tile animations + media lightbox
Features:
- Cards materialize with particle burst on scroll-in
- Cards dissolve with particle scatter on scroll-out
- Photos/videos appear in a front-view lightbox on click
- Energy ring orbits around focused media
=================================================================== */
/* ---------- Particle burst effect (CSS + JS, no Three.js needed) ---------- */
function createParticleBurst(el: HTMLElement, color: string = '#5bd1d8') {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const count = 12;
for (let i = 0; i < count; i++) {
const particle = document.createElement('div');
particle.className = 'webgl-particle-burst';
const angle = (i / count) * Math.PI * 2;
const dist = 40 + Math.random() * 60;
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist;
const size = 2 + Math.random() * 4;
Object.assign(particle.style, {
position: 'fixed',
left: `${cx}px`,
top: `${cy}px`,
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
background: color,
boxShadow: `0 0 ${size * 2}px ${color}`,
pointerEvents: 'none',
zIndex: '9999',
transition: 'all 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
opacity: '1',
});
document.body.appendChild(particle);
requestAnimationFrame(() => {
particle.style.transform = `translate(${dx}px, ${dy}px) scale(0)`;
particle.style.opacity = '0';
});
setTimeout(() => particle.remove(), 700);
}
}
/* ---------- Scroll-triggered card wrapper ---------- */
export function WebGLCard({ children, color = '#5bd1d8', delay = 0 }: {
children: React.ReactNode;
color?: string;
delay?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const hasAnimated = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !hasAnimated.current) {
setTimeout(() => {
setVisible(true);
hasAnimated.current = true;
createParticleBurst(el, color);
}, delay);
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, [color, delay]);
return (
<div
ref={ref}
className={`webgl-card ${visible ? 'webgl-card--visible' : ''}`}
style={{ '--card-accent': color } as React.CSSProperties}
>
{children}
</div>
);
}
/* ---------- Media lightbox (photo/video front view) ---------- */
export function MediaLightbox() {
const [active, setActive] = useState<{ type: 'img' | 'video'; src: string; alt?: string } | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Listen for clicks on photos and videos
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Photo click
if (target.matches('.photo-strip-img, .webgl-media-trigger img')) {
e.preventDefault();
e.stopPropagation();
const img = target as HTMLImageElement;
setActive({ type: 'img', src: img.src, alt: img.alt });
createParticleBurst(target, '#5bd1d8');
}
// Video click
if (target.matches('.video-strip-clip, .webgl-media-trigger video')) {
e.preventDefault();
e.stopPropagation();
const video = target as HTMLVideoElement;
setActive({ type: 'video', src: video.src });
createParticleBurst(target, '#f1c27a');
}
};
document.addEventListener('click', handler, true);
return () => document.removeEventListener('click', handler, true);
}, []);
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setActive(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active]);
if (!active) return null;
return (
<div
ref={overlayRef}
className="media-lightbox"
onClick={(e) => { if (e.target === overlayRef.current) setActive(null); }}
>
{/* Energy ring animation */}
<div className="lightbox-ring lightbox-ring--1" />
<div className="lightbox-ring lightbox-ring--2" />
<div className="lightbox-ring lightbox-ring--3" />
<div className="lightbox-content">
{active.type === 'img' ? (
<img src={active.src} alt={active.alt || ''} className="lightbox-media" />
) : (
<video src={active.src} controls autoPlay muted loop className="lightbox-media" />
)}
<button className="lightbox-close" onClick={() => setActive(null)} aria-label="Fermer">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
);
}
/* ---------- Scroll-triggered section with atomic reveal ---------- */
export function WebGLSection({ children, id, className = '' }: {
children: React.ReactNode;
id?: string;
className?: string;
}) {
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
setVisible(entry.isIntersecting);
},
{ threshold: 0.1 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<section
ref={ref}
id={id}
className={`webgl-section ${visible ? 'webgl-section--visible' : ''} ${className}`}
>
{children}
</section>
);
}
+36 -235
View File
@@ -1,6 +1,5 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import AtomGlitch from '@/components/AtomGlitch';
import '@/styles/global.css';
import '@/styles/home-workbench.css';
import SiteHeader from '@/components/SiteHeader.astro';
@@ -30,114 +29,72 @@ const title = isLaunched
? "Systèmes électroniques spécifiques | L'électron rare"
: "L'Electron Rare — Bientôt";
const description = isLaunched
? "L'Électron Rare conçoit, met au point et fiabilise des systèmes électroniques spécifiques en électronique, automatisme, énergie et stockage."
? "Conception, mise au point et fiabilisation de systèmes électroniques spécifiques."
: "L'Electron Rare arrive. Systèmes électroniques spécifiques, formations, prototypage.";
---
<BaseLayout title={title} description={description}>
{isLaunched ? (
<!-- ============ SITE COMPLET (après 1er mai) ============ -->
<Fragment>
<SiteHeader brandHref="#top" items={headerItems} quickHref="#contact" />
<main id="main-content" class="site-shell studio-structure pb-12 pt-3">
<!-- Hero avec WebGL en background -->
<section class="structure-grid structure-grid--hero hero-webgl-wrapper" data-reveal>
<div class="hero-webgl-bg">
<AtomGlitch client:visible />
</div>
<div class="structure-cell structure-cell--hero hero-webgl-content">
<section class="structure-grid structure-grid--hero" data-reveal>
<div class="structure-cell structure-cell--hero">
<Hero />
</div>
</section>
<section class="structure-grid structure-grid--systems" data-reveal>
<div class="structure-cell structure-cell--about">
<About />
</div>
</section>
<section class="structure-grid structure-grid--cases" data-reveal>
<div class="structure-cell structure-cell--cases">
<CaseStudies />
</div>
</section>
<section class="photo-strip" aria-label="Photos de terrain" data-reveal>
<div class="photo-strip-track">
<img src={withSiteBase('/assets/photos/pcb-teensy-led-kxkm.webp')} alt="PCB custom Teensy LED — projet spectacle vivant KXKM" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/platine-automate-cablage.webp')} alt="Câblage automate industriel — intervention terrain" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/bench-bms-batteries-xt60.webp')} alt="Banc de test BMS batteries — prototypage" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/automate-siemens-s7.webp')} alt="Automate Siemens S7 — diagnostic industriel" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/soudure-pcb-composants.webp')} alt="Soudure PCB — formation et prototypage" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/oscilloscope-philips-vintage.webp')} alt="Instrumentation et mesure — oscilloscope" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/rack-cbtc-lon-connecteurs.webp')} alt="Rack CBTC transport — connectique industrielle" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/pcb-embarquee-boitier-test.webp')} alt="Carte embarquée en test — validation prototype" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/armoire-automate-schneider.webp')} alt="Armoire Schneider — automatisme industriel" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/outillage-atelier-pro.webp')} alt="Atelier professionnel — outillage organisé" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
</div>
</section>
<section class="video-strip" aria-label="Vidéos de terrain" data-reveal>
<div class="video-strip-track">
<video src={withSiteBase('/assets/videos/video-bench-electronique.mp4')} poster={withSiteBase('/assets/videos/video-bench-electronique-poster.webp')} controls muted loop playsinline preload="none" width="320" height="180" class="video-strip-clip" aria-label="Bench électronique en action"></video>
<video src={withSiteBase('/assets/videos/video-test-prototype.mp4')} poster={withSiteBase('/assets/videos/video-test-prototype-poster.webp')} controls muted loop playsinline preload="none" width="320" height="180" class="video-strip-clip" aria-label="Test de prototype"></video>
<video src={withSiteBase('/assets/videos/video-instrumentation.mp4')} poster={withSiteBase('/assets/videos/video-instrumentation-poster.webp')} controls muted loop playsinline preload="none" width="320" height="180" class="video-strip-clip" aria-label="Instrumentation et mesure"></video>
</div>
</section>
<section class="structure-grid structure-grid--sprints" data-reveal>
<div class="structure-cell structure-cell--sprints">
<GraphicSprints />
</div>
</section>
<section class="structure-grid structure-grid--faq" data-reveal>
<div class="structure-cell structure-cell--faq">
<Faq />
</div>
</section>
<section class="structure-grid structure-grid--conversion" data-reveal>
<div class="structure-cell structure-cell--contact">
<Contact />
</div>
</section>
</main>
<footer class="site-footer border-t border-[var(--line)] py-6 text-sm text-[var(--text-muted)]">
<div class="site-shell footer-grid footer-grid--enhanced">
<div class="footer-col footer-col--brand">
<p class="m-0 footer-title">L'électron rare</p>
<div class="footer-brand-meta">
<p class="mb-0 footer-copy">© 2026 Clément Saillant - systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.</p>
<p class="mb-0 footer-copy footer-copy--right">Contact direct : LinkedIn DM ou contact@lelectronrare.fr</p>
<p class="mb-0 footer-copy">© 2026 Clément Saillant</p>
</div>
</div>
<nav aria-label="Ressources studio" class="footer-links footer-links--single-rail">
<nav aria-label="Ressources" class="footer-links footer-links--single-rail">
<a href="#a-propos" class="footer-link">Approche</a>
<a href="#graphic-sprints-title" class="footer-link">Missions</a>
<a href={formationHref} class="footer-link">Formation</a>
<a href="#faq" class="footer-link">FAQ</a>
<a href="#contact" class="footer-link">Contact</a>
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener noreferrer me" {...trackAttrs(TRACK_EVENTS.outboundLinkedinContact, 'linkedin.com')} class="footer-link">LinkedIn</a>
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener noreferrer me" {...trackAttrs(TRACK_EVENTS.outboundGithubContact, 'github.com')} class="footer-link">GitHub</a>
<a href="https://blog.saillant.cc" target="_blank" rel="noopener noreferrer me" class="footer-link">Blog technique</a>
<a href={mentionsHref} class="footer-link">Mentions légales</a>
</nav>
</div>
</footer>
</Fragment>
) : (
<!-- ============ COUNTDOWN (avant 1er mai) ============ -->
<!-- COUNTDOWN — WebGL placeholder -->
<main class="countdown-page">
<div class="webgl-bg">
<AtomGlitch client:load />
</div>
<!-- WebGL goes here -->
<div class="webgl-bg" id="webgl-countdown"></div>
<div class="overlay">
<div class="top-bar">
<div class="logo-sprite" id="logo-sprite" aria-label="L'Electron Rare — logo animé"></div>
<div class="logo-sprite" id="logo-sprite" aria-label="L'Electron Rare"></div>
<span class="brand-mark">L'ELECTRON RARE</span>
</div>
@@ -153,11 +110,10 @@ const description = isLaunched
<span class="bottom-right">mai 2026</span>
</div>
<!-- Cartouche footer -->
<div class="cartouche">
<p class="cartouche-title">L'électron rare</p>
<p class="cartouche-copy">© 2026 Clément Saillant — systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.</p>
<p class="cartouche-contact">Contact direct : <a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener">LinkedIn DM</a> ou <a href="mailto:contact@lelectronrare.fr">contact@lelectronrare.fr</a></p>
<p class="cartouche-contact">Contact : <a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener">LinkedIn</a> · <a href="mailto:contact@lelectronrare.fr">contact@lelectronrare.fr</a></p>
</div>
</div>
</main>
@@ -186,8 +142,9 @@ const description = isLaunched
let msgIndex = Math.floor(Math.random() * messages.length);
function rotateMessage() {
const bubble = document.getElementById('tagline-bubble')!;
const el = document.getElementById('tagline')!;
const bubble = document.getElementById('tagline-bubble');
const el = document.getElementById('tagline');
if (!bubble || !el) return;
bubble.style.opacity = '0';
bubble.style.transform = 'translateY(4px)';
setTimeout(() => {
@@ -198,8 +155,8 @@ const description = isLaunched
}, 400);
}
const sprite = document.getElementById('logo-sprite')!;
sprite.style.backgroundImage = `url(/assets/brand/logo-mark.png)`;
const sprite = document.getElementById('logo-sprite');
if (sprite) sprite.style.backgroundImage = 'url(/assets/brand/logo-mark.png)';
rotateMessage();
setInterval(rotateMessage, 8000);
@@ -207,181 +164,25 @@ const description = isLaunched
)}
<style>
/* ============ COUNTDOWN STYLES ============ */
.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-bg {
position: fixed;
inset: 0;
z-index: 0;
width: 100vw;
height: 100vh;
pointer-events: auto;
}
.overlay {
position: relative;
z-index: 10;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: clamp(20px, 3vw, 40px) clamp(24px, 5vw, 60px);
pointer-events: none;
}
.overlay > * { pointer-events: none; }
.overlay a, .overlay .tagline-area { pointer-events: auto; }
.top-bar {
display: flex;
align-items: center;
gap: 16px;
animation: fade-in 0.8s ease both;
}
.logo-sprite {
width: 48px;
height: 48px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
filter: brightness(1.2) drop-shadow(0 0 12px rgba(91, 209, 216, 0.4));
}
.brand-mark {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.25em;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
}
.center-spacer {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: clamp(20px, 3vw, 40px);
}
.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 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: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: clamp(11px, 1.3vw, 14px);
color: rgba(255, 255, 255, 0.4);
}
.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;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ============ CARTOUCHE FOOTER ============ */
.cartouche {
position: fixed;
bottom: clamp(16px, 2vw, 24px);
right: clamp(16px, 2vw, 24px);
max-width: 320px;
padding: 14px 18px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
pointer-events: auto;
animation: fade-in 0.8s 1.5s ease both;
}
.cartouche-title {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 6px;
letter-spacing: -0.01em;
}
.cartouche-copy {
font-size: 9px;
color: rgba(255, 255, 255, 0.25);
margin: 0 0 4px;
line-height: 1.4;
}
.cartouche-contact {
font-size: 9px;
color: rgba(255, 255, 255, 0.25);
margin: 0;
line-height: 1.4;
}
.cartouche-contact a {
color: #5bd1d8;
text-decoration: none;
}
.cartouche-contact a:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.cartouche {
position: relative;
bottom: auto;
right: auto;
max-width: 100%;
margin-top: 12px;
text-align: center;
}
}
/* ============ HERO WEBGL (site complet) ============ */
.hero-webgl-wrapper {
position: relative;
overflow: hidden;
}
.hero-webgl-bg {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.25;
pointer-events: none;
}
.hero-webgl-content {
position: relative;
z-index: 1;
}
@media (max-width: 640px) {
.bottom-bar { flex-direction: column; align-items: center; gap: 4px; }
}
@media (prefers-reduced-motion: reduce) {
.logo-sprite, .top-bar, .tagline-area, .bottom-bar { animation: none; opacity: 1; }
.webgl-bg, .hero-webgl-bg { display: none; }
}
.countdown-page { position:relative; min-height:100vh; overflow:hidden; background:#000; color:#fff; font-family:'Manrope',-apple-system,sans-serif; }
.webgl-bg { position:fixed; inset:0; z-index:0; width:100vw; height:100vh; pointer-events:auto; }
.overlay { position:relative; z-index:10; min-height:100vh; display:flex; flex-direction:column; justify-content:space-between; padding:clamp(20px,3vw,40px) clamp(24px,5vw,60px); pointer-events:none; }
.overlay > * { pointer-events:none; }
.overlay a { pointer-events:auto; }
.top-bar { display:flex; align-items:center; gap:16px; }
.logo-sprite { width:48px; height:48px; background-size:contain; background-repeat:no-repeat; filter:brightness(1.2) drop-shadow(0 0 12px rgba(91,209,216,0.4)); }
.brand-mark { font-size:11px; font-weight:600; letter-spacing:0.25em; color:rgba(255,255,255,0.5); text-transform:uppercase; }
.center-spacer { flex:1; display:flex; align-items:center; justify-content:center; }
.tagline-area { display:flex; align-items:center; gap:8px; 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; pointer-events:auto; }
.tagline-cursor { font-family:monospace; font-size:14px; color:#5bd1d8; animation:blink 1s step-end infinite; }
@keyframes blink { 50% { opacity:0; } }
.tagline-text { font-family:monospace; font-size:clamp(11px,1.3vw,14px); color:rgba(255,255,255,0.4); }
.bottom-bar { display:flex; justify-content:space-between; align-items:flex-end; }
.bottom-left, .bottom-right { font-size:clamp(10px,1.1vw,12px); color:rgba(255,255,255,0.15); letter-spacing:0.08em; text-transform:uppercase; }
.cartouche { position:fixed; bottom:clamp(16px,2vw,24px); right:clamp(16px,2vw,24px); max-width:320px; padding:14px 18px; background:rgba(0,0,0,0.5); backdrop-filter:blur(12px); border:1px solid rgba(255,255,255,0.06); border-radius:12px; pointer-events:auto; }
.cartouche-title { font-size:13px; font-weight:700; color:rgba(255,255,255,0.6); margin:0 0 6px; }
.cartouche-copy { font-size:9px; color:rgba(255,255,255,0.25); margin:0 0 4px; line-height:1.4; }
.cartouche-contact { font-size:9px; color:rgba(255,255,255,0.25); margin:0; }
.cartouche-contact a { color:#5bd1d8; text-decoration:none; }
@media (max-width:640px) { .bottom-bar{flex-direction:column;align-items:center;gap:4px;} .cartouche{position:relative;bottom:auto;right:auto;max-width:100%;margin-top:12px;text-align:center;} }
</style>
+75 -757
View File
@@ -1,12 +1,8 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import WebGLBackground from '@/components/WebGLBackground';
import { MediaLightbox } from '@/components/WebGLCards';
import '@/styles/global.css';
import '@/styles/home-workbench.css';
import '@/styles/webgl-cards.css';
import SiteHeader from '@/components/SiteHeader.astro';
// Hero removed — PCB 3D is the hero
import { About } from '@/components/sections/About';
import { CaseStudies } from '@/components/sections/CaseStudies';
import { GraphicSprints } from '@/components/sections/GraphicSprints';
@@ -27,7 +23,7 @@ const headerItems = [
---
<BaseLayout>
<!-- Loading screen — shows BMU photo while WebGL loads -->
<!-- Loading screen — BMU photo while WebGL loads -->
<div class="loading-screen" id="loading-screen">
<div class="loading-content">
<img src="/assets/photos/bmu-assembly.jpg" alt="BMU v2 Assembly" class="loading-bmu-img" />
@@ -35,124 +31,67 @@ const headerItems = [
<div class="loading-bar">
<div class="loading-bar-fill" id="loading-bar-fill"></div>
</div>
<p class="loading-status" id="loading-status">Chargement du PCB 3D...</p>
<p class="loading-status" id="loading-status">Chargement...</p>
<a href="/static/" class="loading-static-btn">Version sans 3D →</a>
<p class="loading-tagline">électronique · automatisme · énergie</p>
</div>
</div>
<!-- WebGL background layer — fixed, subtle, always visible -->
<WebGLBackground client:load />
<!-- Media lightbox (click photos/videos to view full) -->
<MediaLightbox client:load />
<!-- WebGL background placeholder — NEW WEBGL GOES HERE -->
<div id="webgl-container"></div>
<SiteHeader brandHref="#top" items={headerItems} quickHref="#contact" />
<main id="main-content" class="dark-site">
<!-- No HTML hero — the PCB 3D IS the hero. First spacer = overview of the board -->
<div class="pcb-spacer pcb-spacer--hero" id="top" aria-hidden="true"></div>
<!-- ABOUT -->
<section class="section-dark webgl-section" id="a-propos">
<div class="section-content webgl-card" style="--card-accent: #5bd1d8">
<section class="section-dark" id="a-propos">
<div class="section-content">
<About />
</div>
</section>
<div class="pcb-spacer" aria-hidden="true"></div>
<!-- CASE STUDIES -->
<section class="section-dark webgl-section">
<div class="section-content webgl-card" style="--card-accent: #f1c27a">
<section class="section-dark">
<div class="section-content">
<CaseStudies />
</div>
</section>
<div class="pcb-spacer" aria-hidden="true"></div>
<!-- PHOTO CAROUSEL (atomic orbit) -->
<section class="section-dark webgl-section atomic-carousel" aria-label="Photos de terrain">
<div class="section-content">
<h2 style="color: rgba(255,255,255,0.4); font-size: 11px; letter-spacing: 0.3em; text-transform: uppercase; text-align: center; margin-bottom: 8px;">Terrain</h2>
<section class="photo-strip section-dark" aria-label="Photos de terrain">
<div class="photo-strip-track">
<img src={withSiteBase('/assets/photos/pcb-teensy-led-kxkm.webp')} alt="PCB custom Teensy LED" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/platine-automate-cablage.webp')} alt="Câblage automate industriel" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/bench-bms-batteries-xt60.webp')} alt="Banc de test BMS batteries" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/automate-siemens-s7.webp')} alt="Automate Siemens S7" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/soudure-pcb-composants.webp')} alt="Soudure PCB" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
<img src={withSiteBase('/assets/photos/oscilloscope-philips-vintage.webp')} alt="Oscilloscope" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
</div>
<button class="atomic-carousel-nav atomic-carousel-nav--prev" data-carousel="photos" data-dir="-1" aria-label="Precedent">&#8249;</button>
<div class="atomic-carousel-orbit" id="photo-orbit">
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/pcb-teensy-led-kxkm.webp')} alt="PCB custom Teensy LED — projet spectacle vivant KXKM" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/platine-automate-cablage.webp')} alt="Câblage automate industriel" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/bench-bms-batteries-xt60.webp')} alt="Banc de test BMS batteries" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/automate-siemens-s7.webp')} alt="Automate Siemens S7" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/soudure-pcb-composants.webp')} alt="Soudure PCB composants" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/oscilloscope-philips-vintage.webp')} alt="Oscilloscope vintage" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/rack-cbtc-lon-connecteurs.webp')} alt="Rack CBTC connectique" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/pcb-embarquee-boitier-test.webp')} alt="Carte embarquée en test" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/armoire-automate-schneider.webp')} alt="Armoire Schneider" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
<div class="atomic-carousel-item" style="width: 300px; height: 200px;">
<img src={withSiteBase('/assets/photos/outillage-atelier-pro.webp')} alt="Atelier professionnel" loading="lazy" decoding="async" class="photo-strip-img" />
</div>
</div>
<button class="atomic-carousel-nav atomic-carousel-nav--next" data-carousel="photos" data-dir="1" aria-label="Suivant">&#8250;</button>
<div class="atomic-carousel-track" id="photo-track"></div>
</section>
<!-- VIDEO CAROUSEL (atomic orbit) -->
<section class="section-dark webgl-section atomic-carousel" aria-label="Vidéos de terrain">
<button class="atomic-carousel-nav atomic-carousel-nav--prev" data-carousel="videos" data-dir="-1" aria-label="Precedent">&#8249;</button>
<div class="atomic-carousel-orbit" id="video-orbit">
<div class="atomic-carousel-item" style="width: 360px; height: 200px;">
<video src={withSiteBase('/assets/videos/video-bench-electronique.mp4')} poster={withSiteBase('/assets/videos/video-bench-electronique-poster.webp')} muted loop playsinline preload="none" class="video-strip-clip" aria-label="Bench électronique"></video>
</div>
<div class="atomic-carousel-item" style="width: 360px; height: 200px;">
<video src={withSiteBase('/assets/videos/video-test-prototype.mp4')} poster={withSiteBase('/assets/videos/video-test-prototype-poster.webp')} muted loop playsinline preload="none" class="video-strip-clip" aria-label="Test prototype"></video>
</div>
<div class="atomic-carousel-item" style="width: 360px; height: 200px;">
<video src={withSiteBase('/assets/videos/video-instrumentation.mp4')} poster={withSiteBase('/assets/videos/video-instrumentation-poster.webp')} muted loop playsinline preload="none" class="video-strip-clip" aria-label="Instrumentation"></video>
</div>
</div>
<button class="atomic-carousel-nav atomic-carousel-nav--next" data-carousel="videos" data-dir="1" aria-label="Suivant">&#8250;</button>
<div class="atomic-carousel-track" id="video-track"></div>
</section>
<div class="pcb-spacer" aria-hidden="true"></div>
<!-- SPRINTS -->
<section class="section-dark webgl-section" id="graphic-sprints-title">
<div class="section-content webgl-card" style="--card-accent: #b6d18f">
<section class="section-dark" id="graphic-sprints-title">
<div class="section-content">
<GraphicSprints />
</div>
</section>
<div class="pcb-spacer" aria-hidden="true"></div>
<!-- FAQ -->
<section class="section-dark webgl-section" id="faq">
<div class="section-content webgl-card" style="--card-accent: #5bd1d8">
<section class="section-dark" id="faq">
<div class="section-content">
<Faq />
</div>
</section>
<!-- CONTACT — hidden by default, appears as 3D tile on click or end of scroll -->
<section class="section-dark section-contact contact-3d-popup" id="contact">
<div class="contact-3d-backdrop" id="contact-backdrop"></div>
<div class="section-content contact-3d-tile" id="contact-tile">
<button class="contact-3d-close" id="contact-close" aria-label="Fermer">✕</button>
<div class="pcb-spacer" aria-hidden="true"></div>
<section class="section-dark" id="contact">
<div class="section-content">
<Contact />
</div>
</section>
@@ -163,697 +102,76 @@ const headerItems = [
<div class="footer-col footer-col--brand">
<p class="m-0 footer-title">L'électron rare</p>
<div class="footer-brand-meta">
<p class="mb-0 footer-copy">© 2026 Clément Saillant - systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.</p>
<p class="mb-0 footer-copy footer-copy--right">Contact direct : LinkedIn DM ou contact@lelectronrare.fr</p>
<p class="mb-0 footer-copy">© 2026 Clément Saillant systèmes électroniques spécifiques, conseil, formation.</p>
<p class="mb-0 footer-copy">Contact : <a href="mailto:contact@lelectronrare.fr">contact@lelectronrare.fr</a></p>
</div>
</div>
<nav aria-label="Ressources studio" class="footer-links footer-links--single-rail">
<nav aria-label="Ressources" class="footer-links footer-links--single-rail">
<a href="#a-propos" class="footer-link">Approche</a>
<a href="#graphic-sprints-title" class="footer-link">Missions</a>
<a href={formationHref} class="footer-link">Formation</a>
<a href="#faq" class="footer-link">FAQ</a>
<a href="#contact" class="footer-link">Contact</a>
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener noreferrer me" {...trackAttrs(TRACK_EVENTS.outboundLinkedinContact, 'linkedin.com')} class="footer-link">LinkedIn</a>
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener noreferrer me" {...trackAttrs(TRACK_EVENTS.outboundGithubContact, 'github.com')} class="footer-link">GitHub</a>
<a href="https://blog.saillant.cc" target="_blank" rel="noopener noreferrer me" class="footer-link">Blog technique</a>
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener" class="footer-link">LinkedIn</a>
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener" class="footer-link">GitHub</a>
<a href={mentionsHref} class="footer-link">Mentions légales</a>
</nav>
</div>
</footer>
</BaseLayout>
<style>
/* ============ DARK TECH THEME ============ */
.dark-site {
position: relative;
z-index: 1;
background: transparent;
color: #e0e0e0;
}
.section-dark {
position: relative;
padding: clamp(40px, 8vw, 100px) 0;
}
.section-hero {
min-height: 100vh;
display: flex;
align-items: center;
}
.section-content {
position: relative;
z-index: 2;
max-width: 1200px;
margin: 0 auto;
padding: 0 clamp(20px, 4vw, 48px);
}
/* Glass cards for content sections */
.section-dark :global(.circuit-board-section),
.section-dark :global(.about-card),
.section-dark :global(.case-card),
.section-dark :global(.sprint-card) {
background: rgba(255, 255, 255, 0.03) !important;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06) !important;
color: #e0e0e0 !important;
}
/* Text overrides for dark */
.section-dark :global(h1),
.section-dark :global(h2),
.section-dark :global(h3),
.section-dark :global(.section-title),
.section-dark :global(.hero-title-word) {
color: #ffffff !important;
}
.section-dark :global(p),
.section-dark :global(li),
.section-dark :global(.about-bio),
.section-dark :global(.case-text),
.section-dark :global(.sprint-deliverables) {
color: rgba(255, 255, 255, 0.7) !important;
}
.section-dark :global(a) {
color: #5bd1d8 !important;
}
.section-dark :global(.hero-slogan-char) {
color: rgba(91, 209, 216, 0.8) !important;
}
/* Photo strip dark */
.section-dark.photo-strip {
padding: clamp(20px, 4vw, 40px) 0;
}
.section-dark :global(.photo-strip-img) {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
opacity: 0.85;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.section-dark :global(.photo-strip-img:hover) {
opacity: 1;
transform: scale(1.03);
}
/* Video strip dark */
.section-dark :global(.video-strip-clip) {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
/* FAQ dark */
.section-dark :global(.faq-question) {
color: #ffffff !important;
}
.section-dark :global(.faq-answer) {
color: rgba(255, 255, 255, 0.6) !important;
}
.section-dark :global(details) {
border-color: rgba(255, 255, 255, 0.08) !important;
}
/* Chips/badges glow */
.section-dark :global(.about-chip) {
border-color: rgba(255, 255, 255, 0.1) !important;
background: rgba(255, 255, 255, 0.04) !important;
}
/* Sprint progress bars glow */
.section-dark :global(.sprint-progress-fill) {
box-shadow: 0 0 12px rgba(91, 209, 216, 0.4);
}
/* CTA buttons */
.section-dark :global(.btn-primary),
.section-dark :global([data-track]) {
background: rgba(91, 209, 216, 0.15) !important;
border: 1px solid rgba(91, 209, 216, 0.3) !important;
color: #5bd1d8 !important;
}
.section-dark :global(.btn-primary:hover) {
background: rgba(91, 209, 216, 0.25) !important;
}
/* Contact form dark (Minitel) */
.section-dark :global(.minitel-body) {
background: rgba(0, 0, 0, 0.6) !important;
backdrop-filter: blur(16px);
}
.section-dark :global(.minitel-input),
.section-dark :global(.minitel-textarea),
.section-dark :global(.minitel-select) {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: #e0e0e0 !important;
}
/* Hero background image — very subtle dark blend */
.section-dark :global(.figma-lab-hero-grid) {
position: relative;
}
.section-dark :global(.hero-panel-image) {
opacity: 0.1 !important;
mix-blend-mode: luminosity;
}
/* ============ DARK FOOTER ============ */
.dark-footer {
position: relative;
z-index: 2;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 24px 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
}
.dark-footer :global(.footer-title) {
color: #ffffff;
}
.dark-footer :global(.footer-copy) {
color: rgba(255, 255, 255, 0.4);
}
.dark-footer :global(.footer-link) {
color: rgba(255, 255, 255, 0.5) !important;
}
.dark-footer :global(.footer-link:hover) {
color: #5bd1d8 !important;
}
/* ============ LOADING SCREEN ============ */
.loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
background: #000;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 1s ease, visibility 1s ease;
}
.loading-screen.loading-done {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 20px;
padding: 24px;
animation: loading-fade-in 0.8s ease both;
}
@keyframes loading-fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.loading-bmu-img {
width: clamp(200px, 50vw, 400px);
height: auto;
border-radius: 16px;
border: 1px solid rgba(91, 209, 216, 0.15);
box-shadow: 0 0 40px rgba(91, 209, 216, 0.08), 0 12px 40px rgba(0,0,0,0.5);
animation: loading-float 4s ease-in-out infinite;
}
@keyframes loading-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.loading-title {
font-family: 'Manrope', -apple-system, sans-serif;
font-size: clamp(28px, 5vw, 48px);
font-weight: 800;
color: #ffffff;
margin: 0;
letter-spacing: -0.03em;
text-shadow: 0 0 30px rgba(91, 209, 216, 0.2);
}
.loading-electric {
color: #5bd1d8;
text-shadow: 0 0 15px rgba(91, 209, 216, 0.6);
}
.loading-bar {
width: clamp(200px, 40vw, 300px);
height: 2px;
background: rgba(255, 255, 255, 0.06);
border-radius: 1px;
overflow: hidden;
}
.loading-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #5bd1d8, #f1c27a);
border-radius: 1px;
transition: width 0.3s ease;
}
.loading-status {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
margin: 0;
}
.loading-static-btn {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
color: rgba(91, 209, 216, 0.6);
text-decoration: none;
padding: 8px 16px;
border: 1px solid rgba(91, 209, 216, 0.15);
border-radius: 8px;
transition: all 0.2s ease;
pointer-events: auto;
}
.loading-static-btn:hover {
color: #5bd1d8;
border-color: rgba(91, 209, 216, 0.4);
background: rgba(91, 209, 216, 0.05);
}
.loading-tagline {
font-size: 11px;
color: rgba(255, 255, 255, 0.12);
letter-spacing: 0.1em;
text-transform: uppercase;
margin: 0;
}
/* ============ HERO BMU ============ */
.hero-bmu {
position: relative;
z-index: 2;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: clamp(60px, 10vw, 120px) clamp(20px, 4vw, 48px);
gap: clamp(24px, 4vw, 48px);
}
.hero-bmu-title {
animation: hero-title-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes hero-title-in {
from { opacity: 0; transform: translateY(30px); filter: blur(8px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
.hero-bmu-h1 {
font-family: 'Manrope', -apple-system, sans-serif;
font-size: clamp(36px, 7vw, 72px);
font-weight: 800;
color: #ffffff;
margin: 0;
letter-spacing: -0.03em;
line-height: 1;
text-shadow: 0 0 40px rgba(91, 209, 216, 0.2);
}
.hero-electric {
color: #5bd1d8;
text-shadow: 0 0 20px rgba(91, 209, 216, 0.6);
}
.hero-bmu-sub {
font-size: clamp(14px, 2vw, 18px);
color: rgba(255, 255, 255, 0.4);
margin: 12px 0 0;
font-weight: 400;
letter-spacing: 0.02em;
max-width: 500px;
}
/* Navigation zones — PCB component style */
.hero-bmu-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: clamp(10px, 2vw, 20px);
max-width: 700px;
animation: hero-nav-in 1s 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes hero-nav-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-bmu-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: clamp(14px, 2vw, 24px) clamp(18px, 3vw, 32px);
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
text-decoration: none;
color: #fff;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
min-width: 120px;
position: relative;
overflow: hidden;
}
.hero-bmu-zone::before {
content: '';
position: absolute;
inset: -1px;
border-radius: 16px;
padding: 1px;
background: linear-gradient(135deg, var(--zone-color, #5bd1d8) 0%, transparent 50%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.3s ease;
}
.hero-bmu-zone:hover {
background: rgba(255, 255, 255, 0.06);
transform: translateY(-4px) scale(1.03);
box-shadow: 0 0 24px rgba(91, 209, 216, 0.1), 0 8px 32px rgba(0, 0, 0, 0.3);
border-color: rgba(91, 209, 216, 0.15);
}
.hero-bmu-zone:hover::before { opacity: 0.5; }
.hero-bmu-zone--mcu { --zone-color: #5bd1d8; }
.hero-bmu-zone--analog { --zone-color: #f1c27a; }
.hero-bmu-zone--mission { --zone-color: #b6d18f; }
.hero-bmu-zone--formation { --zone-color: #ff9f0a; }
.hero-bmu-zone--contact { --zone-color: #ff6b35; }
.hero-bmu-zone-icon {
font-size: 24px;
opacity: 0.6;
color: var(--zone-color, #5bd1d8);
filter: drop-shadow(0 0 6px var(--zone-color, #5bd1d8));
}
.hero-bmu-zone-label {
font-family: 'Orbitron', 'Manrope', sans-serif;
font-size: clamp(11px, 1.3vw, 14px);
font-weight: 700;
letter-spacing: 0.15em;
color: var(--zone-color, #5bd1d8);
}
.hero-bmu-zone-desc {
font-size: clamp(10px, 1vw, 12px);
color: rgba(255, 255, 255, 0.35);
font-weight: 400;
}
.hero-bmu-tagline {
animation: hero-tag-in 0.8s 0.6s ease both;
}
@keyframes hero-tag-in {
from { opacity: 0; }
to { opacity: 1; }
}
.hero-bmu-tagline span {
font-size: clamp(10px, 1.2vw, 13px);
color: rgba(255, 255, 255, 0.15);
letter-spacing: 0.1em;
text-transform: uppercase;
}
@media (max-width: 640px) {
.hero-bmu-nav { flex-direction: column; align-items: stretch; }
.hero-bmu-zone { flex-direction: row; gap: 12px; text-align: left; }
}
/* ============ PCB SPACER ============ */
.pcb-spacer {
height: 60vh;
pointer-events: none;
}
.pcb-spacer--hero {
height: 100vh;
}
/* ============ CONTACT 3D POPUP ============ */
.contact-3d-popup {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.4s ease, visibility 0.4s ease;
padding: 0;
}
.contact-3d-popup.contact-3d-popup--open {
pointer-events: auto;
opacity: 1;
visibility: visible;
}
.contact-3d-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.contact-3d-tile {
position: relative;
z-index: 2;
max-width: 600px;
width: 90vw;
max-height: 85vh;
overflow-y: auto;
background: rgba(10, 10, 10, 0.9);
border: 1px solid rgba(91, 209, 216, 0.15);
border-radius: 20px;
padding: clamp(20px, 4vw, 40px);
box-shadow:
0 0 40px rgba(91, 209, 216, 0.08),
0 24px 80px rgba(0, 0, 0, 0.6);
transform: perspective(1200px) rotateY(-15deg) rotateX(5deg) scale(0.85) translateY(40px);
opacity: 0;
transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.5s ease;
}
.contact-3d-popup--open .contact-3d-tile {
transform: perspective(1200px) rotateY(0deg) rotateX(0deg) scale(1) translateY(0);
opacity: 1;
}
.contact-3d-close {
position: absolute;
top: 12px;
right: 16px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
width: 32px;
height: 32px;
color: rgba(255, 255, 255, 0.6);
font-size: 16px;
cursor: pointer;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.contact-3d-close:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
.section-hero { min-height: auto; }
.contact-3d-tile { transform: none; }
}
</style>
<script>
// Initialize scroll-triggered webgl-card and webgl-section animations
function initScrollAnimations() {
const cards = document.querySelectorAll('.webgl-card');
const sections = document.querySelectorAll('.webgl-section');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add(
entry.target.classList.contains('webgl-card')
? 'webgl-card--visible'
: 'webgl-section--visible'
);
}
});
},
{ threshold: 0.15 }
);
cards.forEach((el) => observer.observe(el));
sections.forEach((el) => observer.observe(el));
}
// 3D tilt on scroll — cards tilt based on their position in viewport
function initScrollTilt() {
const cards = document.querySelectorAll('.webgl-card--visible');
function updateTilt() {
cards.forEach((card) => {
const rect = card.getBoundingClientRect();
const vh = window.innerHeight;
const center = rect.top + rect.height / 2;
const ratio = (center - vh / 2) / (vh / 2); // -1 (top) to 1 (bottom)
const rotateX = ratio * -3;
const rotateY = ratio * 1.5;
(card as HTMLElement).style.transform =
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
});
requestAnimationFrame(updateTilt);
}
updateTilt();
}
// Carousel navigation
function initCarousels() {
document.querySelectorAll('.atomic-carousel-nav').forEach((btn) => {
btn.addEventListener('click', () => {
const dir = parseInt(btn.getAttribute('data-dir') || '1');
const carouselType = btn.getAttribute('data-carousel');
const orbit = document.getElementById(carouselType === 'photos' ? 'photo-orbit' : 'video-orbit');
if (!orbit) return;
const itemWidth = orbit.querySelector('.atomic-carousel-item')?.clientWidth || 320;
orbit.scrollBy({ left: dir * (itemWidth + 24), behavior: 'smooth' });
});
});
// Track position indicator
['photo-orbit', 'video-orbit'].forEach((id) => {
const orbit = document.getElementById(id);
const track = document.getElementById(id.replace('orbit', 'track'));
if (!orbit || !track) return;
orbit.addEventListener('scroll', () => {
const pct = orbit.scrollLeft / (orbit.scrollWidth - orbit.clientWidth) * 100;
track.style.setProperty('--track-position', `${pct}%`);
});
});
// Video hover play
document.querySelectorAll('.atomic-carousel-item video').forEach((video) => {
const v = video as HTMLVideoElement;
v.closest('.atomic-carousel-item')?.addEventListener('mouseenter', () => v.play());
v.closest('.atomic-carousel-item')?.addEventListener('mouseleave', () => { v.pause(); v.currentTime = 0; });
});
}
// Run on load and on Astro page transitions
initScrollAnimations();
setTimeout(initScrollTilt, 2000);
initCarousels();
// Contact 3D popup
function initContactPopup() {
const popup = document.getElementById('contact');
const backdrop = document.getElementById('contact-backdrop');
const closeBtn = document.getElementById('contact-close');
if (!popup) return;
function openContact() {
popup.classList.add('contact-3d-popup--open');
}
function closeContact() {
popup.classList.remove('contact-3d-popup--open');
}
// Open on any #contact link click
document.querySelectorAll('a[href="#contact"]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
openContact();
});
});
// Open at end of scroll (>95%)
let endTriggered = false;
window.addEventListener('scroll', () => {
const el = document.documentElement;
const pct = el.scrollTop / (el.scrollHeight - el.clientHeight);
if (pct > 0.95 && !endTriggered) {
endTriggered = true;
openContact();
}
if (pct < 0.9) endTriggered = false;
}, { passive: true });
// Close
backdrop?.addEventListener('click', closeContact);
closeBtn?.addEventListener('click', closeContact);
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeContact();
});
}
// Loading screen — fade out when WebGL canvas renders
const loadingMessages = [
"Chargement du PCB 3D...",
"Initialisation des composants...",
"Soudure des connexions...",
"Calibration de la caméra...",
"Vérification des pistes cuivre...",
"Flash du rendu WebGL...",
];
let loadMsgIdx = 0;
// Loading screen — auto-hide after 10s or when ready
const loadScreen = document.getElementById('loading-screen');
const loadBar = document.getElementById('loading-bar-fill');
const loadStatus = document.getElementById('loading-status');
const loadScreen = document.getElementById('loading-screen');
const loadInterval = setInterval(() => {
loadMsgIdx = (loadMsgIdx + 1) % loadingMessages.length;
if (loadStatus) loadStatus.textContent = loadingMessages[loadMsgIdx];
if (loadBar) {
const pct = Math.min(90, parseFloat(loadBar.style.width || '0') + 15);
loadBar.style.width = pct + '%';
}
}, 800);
const msgs = ["Chargement...", "Initialisation...", "Préparation du rendu..."];
let idx = 0;
const interval = setInterval(() => {
idx = (idx + 1) % msgs.length;
if (loadStatus) loadStatus.textContent = msgs[idx];
if (loadBar) loadBar.style.width = Math.min(90, parseFloat(loadBar.style.width || '0') + 20) + '%';
}, 1500);
// Force 10s minimum loading, then fade out
setTimeout(() => {
if (loadBar) loadBar.style.width = '100%';
if (loadStatus) loadStatus.textContent = 'PCB prêt.';
clearInterval(loadInterval);
setTimeout(() => {
loadScreen?.classList.add('loading-done');
}, 600);
if (loadStatus) loadStatus.textContent = 'Prêt.';
clearInterval(interval);
setTimeout(() => loadScreen?.classList.add('loading-done'), 500);
}, 10000);
// Hero BMU zone smooth scroll
document.querySelectorAll('.hero-bmu-zone[href^="#"]').forEach(link => {
link.addEventListener('click', (e) => {
const href = link.getAttribute('href');
if (href === '#contact') return;
e.preventDefault();
const target = document.querySelector(href);
if (target) target.scrollIntoView({ behavior: 'smooth' });
});
});
initContactPopup();
document.addEventListener('astro:page-load', () => {
initScrollAnimations();
setTimeout(initScrollTilt, 2000);
initCarousels();
initContactPopup();
});
</script>
<style>
/* Loading screen */
.loading-screen { position:fixed; inset:0; z-index:9999; background:#000; display:flex; align-items:center; justify-content:center; transition:opacity 1s ease, visibility 1s ease; }
.loading-screen.loading-done { opacity:0; visibility:hidden; pointer-events:none; }
.loading-content { display:flex; flex-direction:column; align-items:center; text-align:center; gap:20px; padding:24px; }
.loading-bmu-img { width:clamp(200px,50vw,400px); height:auto; border-radius:16px; border:1px solid rgba(91,209,216,0.15); box-shadow:0 0 40px rgba(91,209,216,0.08); }
.loading-title { font-size:clamp(28px,5vw,48px); font-weight:800; color:#fff; margin:0; }
.loading-electric { color:#5bd1d8; }
.loading-bar { width:clamp(200px,40vw,300px); height:2px; background:rgba(255,255,255,0.06); border-radius:1px; overflow:hidden; }
.loading-bar-fill { height:100%; width:0%; background:linear-gradient(90deg,#5bd1d8,#f1c27a); transition:width 0.3s ease; }
.loading-status { font-family:monospace; font-size:12px; color:rgba(255,255,255,0.3); margin:0; }
.loading-static-btn { font-size:11px; color:rgba(91,209,216,0.6); text-decoration:none; padding:8px 16px; border:1px solid rgba(91,209,216,0.15); border-radius:8px; }
.loading-static-btn:hover { color:#5bd1d8; border-color:rgba(91,209,216,0.4); }
.loading-tagline { font-size:11px; color:rgba(255,255,255,0.12); letter-spacing:0.1em; text-transform:uppercase; margin:0; }
/* Dark site base */
.dark-site { position:relative; z-index:1; background:transparent; color:#e0e0e0; }
.section-dark { padding:clamp(40px,8vw,100px) 0; }
.section-content { max-width:1200px; margin:0 auto; padding:0 clamp(20px,4vw,48px); }
.pcb-spacer { height:60vh; }
.pcb-spacer--hero { height:100vh; }
/* Dark overrides */
.section-dark :global(h1), .section-dark :global(h2), .section-dark :global(h3) { color:#fff !important; }
.section-dark :global(p), .section-dark :global(li) { color:rgba(255,255,255,0.7) !important; }
.section-dark :global(a) { color:#5bd1d8 !important; }
/* Dark footer */
.dark-footer { position:relative; z-index:2; border-top:1px solid rgba(255,255,255,0.06); padding:24px 0; background:rgba(0,0,0,0.4); }
.dark-footer :global(.footer-title) { color:#fff; }
.dark-footer :global(.footer-copy) { color:rgba(255,255,255,0.4); }
.dark-footer :global(.footer-link) { color:rgba(255,255,255,0.5) !important; }
</style>
-460
View File
@@ -1,460 +0,0 @@
/* ═══════════════════════════════════════════════════════
WEBGL CARDS — Atomic scroll animations + lightbox
═══════════════════════════════════════════════════════ */
/* ── Card materialize animation ─────────────────────── */
.webgl-card {
opacity: 0;
transform: translateY(30px) scale(0.95);
filter: blur(6px);
transition:
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
filter 0.8s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.4s ease;
position: relative;
overflow: hidden;
}
.webgl-card--visible {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
/* Glow border on appear */
.webgl-card--visible::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
var(--card-accent, #5bd1d8) 0%,
transparent 40%,
transparent 60%,
var(--card-accent, #5bd1d8) 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
animation: glow-border-in 1.2s 0.3s ease forwards;
}
@keyframes glow-border-in {
0% { opacity: 0; }
50% { opacity: 0.6; }
100% { opacity: 0.15; }
}
/* Card hover tilt effect */
.webgl-card--visible:hover {
box-shadow:
0 0 20px rgba(91, 209, 216, 0.08),
0 8px 32px rgba(0, 0, 0, 0.3);
transform: translateY(-2px) scale(1.01);
}
/* ── Section atomic reveal — alternate orbit direction ── */
.webgl-section {
opacity: 0;
transition:
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Odd sections slide from left orbit */
.webgl-section:nth-child(odd) {
transform: translateX(-60px) rotate(-1deg);
}
/* Even sections slide from right orbit */
.webgl-section:nth-child(even) {
transform: translateX(60px) rotate(1deg);
}
.webgl-section--visible {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
/* Staggered children reveal */
.webgl-section--visible > *:nth-child(1) { transition-delay: 0s; }
.webgl-section--visible > *:nth-child(2) { transition-delay: 0.1s; }
.webgl-section--visible > *:nth-child(3) { transition-delay: 0.2s; }
.webgl-section--visible > *:nth-child(4) { transition-delay: 0.3s; }
.webgl-section--visible > *:nth-child(5) { transition-delay: 0.4s; }
/* ── Photo/Video hover effect ───────────────────────── */
.photo-strip-img,
.video-strip-clip {
cursor: pointer;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s ease, filter 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
}
.photo-strip-img:hover,
.video-strip-clip:hover {
transform: scale(1.05) translateY(-4px);
box-shadow:
0 0 24px rgba(91, 209, 216, 0.15),
0 12px 40px rgba(0, 0, 0, 0.4);
filter: brightness(1.1);
border-color: rgba(91, 209, 216, 0.2);
}
/* ── Lightbox ───────────────────────────────────────── */
.media-lightbox {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
animation: lightbox-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
cursor: pointer;
}
@keyframes lightbox-in {
from { opacity: 0; }
to { opacity: 1; }
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 85vh;
cursor: default;
animation: lightbox-content-in 0.5s 0.1s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes lightbox-content-in {
from {
opacity: 0;
transform: scale(0.8) translateY(20px);
filter: blur(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0);
}
}
.lightbox-media {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 0 60px rgba(91, 209, 216, 0.1),
0 24px 80px rgba(0, 0, 0, 0.5);
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.2s ease;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.15);
color: #ffffff;
transform: rotate(90deg);
}
/* ── Energy rings around lightbox media ──────────────── */
.lightbox-ring {
position: absolute;
top: 50%;
left: 50%;
border: 1px solid rgba(91, 209, 216, 0.15);
border-radius: 50%;
pointer-events: none;
}
.lightbox-ring--1 {
width: 110%;
height: 110%;
transform: translate(-50%, -50%) rotate(0deg);
animation: ring-orbit 8s linear infinite;
border-style: dashed;
border-color: rgba(91, 209, 216, 0.1);
}
.lightbox-ring--2 {
width: 120%;
height: 120%;
transform: translate(-50%, -50%) rotate(0deg);
animation: ring-orbit 12s linear infinite reverse;
border-color: rgba(241, 194, 122, 0.08);
}
.lightbox-ring--3 {
width: 130%;
height: 60%;
transform: translate(-50%, -50%) rotate(30deg);
animation: ring-orbit 6s linear infinite;
border-color: rgba(182, 209, 143, 0.06);
}
@keyframes ring-orbit {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* ── Particle burst (injected by JS) ─────────────────── */
.webgl-particle-burst {
will-change: transform, opacity;
}
/* ── 3D Tilt on scroll ──────────────────────────────── */
.webgl-card {
perspective: 1000px;
transform-style: preserve-3d;
}
/* Alternating left/right orbit entry */
.webgl-card--visible {
animation: card-orbit-right 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Even cards enter from right, odd from left */
.webgl-section:nth-child(odd) .webgl-card--visible {
animation-name: card-orbit-left;
}
.webgl-section:nth-child(even) .webgl-card--visible {
animation-name: card-orbit-right;
}
@keyframes card-orbit-left {
0% {
opacity: 0;
transform: perspective(1200px) translateX(-120px) rotateY(25deg) rotateZ(-3deg) scale(0.85);
filter: blur(8px);
}
40% {
opacity: 0.7;
transform: perspective(1200px) translateX(-20px) rotateY(8deg) rotateZ(-1deg) scale(0.97);
filter: blur(2px);
}
70% {
opacity: 1;
transform: perspective(1200px) translateX(8px) rotateY(-2deg) rotateZ(0.5deg) scale(1.01);
filter: blur(0);
}
100% {
opacity: 1;
transform: perspective(1200px) translateX(0) rotateY(0deg) rotateZ(0deg) scale(1);
filter: blur(0);
}
}
@keyframes card-orbit-right {
0% {
opacity: 0;
transform: perspective(1200px) translateX(120px) rotateY(-25deg) rotateZ(3deg) scale(0.85);
filter: blur(8px);
}
40% {
opacity: 0.7;
transform: perspective(1200px) translateX(20px) rotateY(-8deg) rotateZ(1deg) scale(0.97);
filter: blur(2px);
}
70% {
opacity: 1;
transform: perspective(1200px) translateX(-8px) rotateY(2deg) rotateZ(-0.5deg) scale(1.01);
filter: blur(0);
}
100% {
opacity: 1;
transform: perspective(1200px) translateX(0) rotateY(0deg) rotateZ(0deg) scale(1);
filter: blur(0);
}
}
/* Mouse-driven 3D tilt on hover */
.webgl-card--visible:hover {
transition: box-shadow 0.3s ease;
box-shadow:
0 0 24px rgba(91, 209, 216, 0.08),
0 12px 40px rgba(0, 0, 0, 0.35);
}
/* ── Atomic carousel for photos/videos ─────────────── */
.atomic-carousel {
position: relative;
padding: clamp(40px, 6vw, 80px) 0;
overflow: visible;
}
.atomic-carousel-orbit {
display: flex;
gap: clamp(12px, 2vw, 24px);
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
padding: 20px clamp(20px, 4vw, 48px);
-ms-overflow-style: none;
scrollbar-width: none;
}
.atomic-carousel-orbit::-webkit-scrollbar {
display: none;
}
.atomic-carousel-item {
flex: 0 0 auto;
scroll-snap-align: center;
position: relative;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
transform: perspective(800px) rotateY(0deg);
}
.atomic-carousel-item::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
border: 1px solid rgba(91, 209, 216, 0.08);
pointer-events: none;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
z-index: 2;
}
.atomic-carousel-item:hover {
transform: perspective(800px) rotateY(-3deg) translateY(-8px) scale(1.04);
z-index: 10;
}
.atomic-carousel-item:hover::before {
border-color: rgba(91, 209, 216, 0.3);
box-shadow:
0 0 30px rgba(91, 209, 216, 0.12),
inset 0 0 20px rgba(91, 209, 216, 0.03);
}
/* Electron dots orbiting around hovered item */
.atomic-carousel-item:hover::after {
content: '';
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background: #5bd1d8;
box-shadow: 0 0 12px #5bd1d8;
top: -3px;
left: 50%;
z-index: 3;
animation: electron-orbit-item 2s linear infinite;
}
@keyframes electron-orbit-item {
0% { top: -3px; left: 50%; }
25% { top: 50%; left: calc(100% + 3px); }
50% { top: calc(100% + 3px); left: 50%; }
75% { top: 50%; left: -3px; }
100% { top: -3px; left: 50%; }
}
.atomic-carousel-item img,
.atomic-carousel-item video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: filter 0.3s ease, transform 0.3s ease;
}
.atomic-carousel-item:hover img,
.atomic-carousel-item:hover video {
filter: brightness(1.15) contrast(1.05);
transform: scale(1.02);
}
/* Carousel navigation arrows */
.atomic-carousel-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(91, 209, 216, 0.1);
border: 1px solid rgba(91, 209, 216, 0.2);
color: #5bd1d8;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
transition: all 0.2s ease;
backdrop-filter: blur(8px);
}
.atomic-carousel-nav:hover {
background: rgba(91, 209, 216, 0.2);
transform: translateY(-50%) scale(1.1);
box-shadow: 0 0 20px rgba(91, 209, 216, 0.2);
}
.atomic-carousel-nav--prev { left: 8px; }
.atomic-carousel-nav--next { right: 8px; }
/* Orbital track line under carousel */
.atomic-carousel-track {
height: 1px;
margin: 16px auto 0;
max-width: 200px;
background: linear-gradient(90deg, transparent, rgba(91, 209, 216, 0.3), transparent);
position: relative;
}
.atomic-carousel-track::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: #5bd1d8;
box-shadow: 0 0 10px #5bd1d8;
top: -3px;
left: var(--track-position, 50%);
transition: left 0.3s ease;
}
/* ── Reduced motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.webgl-card,
.webgl-section {
opacity: 1;
transform: none;
filter: none;
transition: none;
}
.webgl-card--visible::before { display: none; }
.lightbox-ring { display: none; }
.lightbox-content { animation: none; opacity: 1; transform: none; }
}