diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index aada202d..db865fab 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -286,7 +286,7 @@ jobs: AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW" # Gatekeeper Origin - GATEKEEPER_ORIGIN="https://$GATEKEEPER_HOST" + GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper" { echo "# Generated by CI - $TARGET" diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index 033aa31d..b824dd01 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -86,7 +86,7 @@ export default function Hero({ data }: { data?: any }) { -
+
diff --git a/components/home/HeroIllustration.tsx b/components/home/HeroIllustration.tsx index 1b065bcb..fbd4283f 100644 --- a/components/home/HeroIllustration.tsx +++ b/components/home/HeroIllustration.tsx @@ -1,410 +1,643 @@ +/* eslint-disable react/no-unknown-property */ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useRef, useMemo, useState, useEffect } from 'react'; +import { Canvas, useFrame } from '@react-three/fiber'; +import { OrbitControls, PerspectiveCamera, Stars, Line } from '@react-three/drei'; +import { EffectComposer, Bloom } from '@react-three/postprocessing'; +import * as THREE from 'three'; -// Isometric grid configuration - true 2:1 isometric projection -const CELL_WIDTH = 120; -const CELL_HEIGHT = 60; // Half of width for 2:1 isometric +// ═══════════════════════════════════════════════════════════════ +// CORE ALGORITHMS +// ═══════════════════════════════════════════════════════════════ -// Convert grid coordinates to isometric screen coordinates -function gridToScreen(col: number, row: number): { x: number; y: number } { - return { - x: (col - row) * (CELL_WIDTH / 2), - y: (col + row) * (CELL_HEIGHT / 2), - }; +// Deterministic hash +function sr(seed: number) { + const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453; + return x - Math.floor(x); } -// Grid layout (10 columns x 8 rows) -const GRID = { - cols: 10, - rows: 8, -}; +// ── TERRAIN HEIGHT FUNCTION ── +// This is the SINGLE SOURCE OF TRUTH for terrain elevation. +// Used by BOTH the terrain mesh AND all object placement. +const TERRAIN_R = 260; +function terrainY(x: number, z: number): number { + const dist = Math.sqrt(x * x + z * z); + const edge = Math.min(1, dist / TERRAIN_R); + const h = Math.max(0, 1 - edge * 1.3); + let y = 0; + y += Math.sin(x * 0.05) * Math.cos(z * 0.04) * 1.3 * h; + y += Math.sin(x * 0.1 + 2) * Math.cos(z * 0.08 + 1) * 0.5 * h; + y += Math.sin(x * 0.02 + 5) * Math.cos(z * 0.025 + 3) * 1.0 * h; + y -= edge * edge * 25; + return y; +} -// Infrastructure positions -const INFRASTRUCTURE = { - solar: [ - { col: 0, row: 5 }, - { col: 1, row: 5 }, - { col: 0, row: 6 }, - { col: 1, row: 6 }, - { col: 2, row: 7 }, - { col: 3, row: 7 }, - { col: 2, row: 8 }, - { col: 3, row: 8 }, - ], - wind: [ - { col: 0, row: 1 }, - { col: 1, row: 2 }, - { col: 2, row: 1 }, - { col: 3, row: 0 }, - { col: 4, row: 1 }, - { col: 5, row: 0 }, - ], - substations: [ - { col: 3, row: 3, type: 'collection' }, - { col: 6, row: 4, type: 'distribution' }, - { col: 5, row: 7, type: 'distribution' }, - ], - towers: [ - { col: 4, row: 3 }, - { col: 5, row: 4 }, - { col: 4, row: 5 }, - { col: 5, row: 6 }, - ], - city: [ - { col: 8, row: 3, type: 'tall' }, - { col: 9, row: 4, type: 'medium' }, - { col: 8, row: 5, type: 'small' }, - { col: 9, row: 5, type: 'medium' }, - ], - city2: [ - { col: 6, row: 8, type: 'medium' }, - { col: 7, row: 7, type: 'tall' }, - { col: 7, row: 8, type: 'small' }, - ], - trees: [ - { col: 0, row: 3 }, - { col: 2, row: 6 }, - { col: 3, row: 1 }, - { col: 6, row: 2 }, - { col: 6, row: 6 }, - ], -}; +// Place on terrain — EVERY object uses this +function onTerrain(x: number, z: number): THREE.Vector3 { + return new THREE.Vector3(x, terrainY(x, z), z); +} -const POWER_LINES = [ - { from: { col: 0, row: 1 }, to: { col: 1, row: 1 } }, - { from: { col: 1, row: 2 }, to: { col: 1, row: 1 } }, - { from: { col: 2, row: 1 }, to: { col: 1, row: 1 } }, - { from: { col: 1, row: 1 }, to: { col: 1, row: 3 } }, - { from: { col: 1, row: 3 }, to: { col: 3, row: 3 } }, - { from: { col: 3, row: 0 }, to: { col: 4, row: 0 } }, - { from: { col: 4, row: 0 }, to: { col: 4, row: 1 } }, - { from: { col: 5, row: 0 }, to: { col: 5, row: 1 } }, - { from: { col: 5, row: 1 }, to: { col: 4, row: 1 } }, - { from: { col: 4, row: 1 }, to: { col: 4, row: 3 } }, - { from: { col: 4, row: 3 }, to: { col: 3, row: 3 } }, - { from: { col: 0, row: 5 }, to: { col: 1, row: 5 } }, - { from: { col: 0, row: 6 }, to: { col: 0, row: 5 } }, - { from: { col: 1, row: 6 }, to: { col: 1, row: 5 } }, - { from: { col: 1, row: 5 }, to: { col: 1, row: 3 } }, - { from: { col: 2, row: 7 }, to: { col: 3, row: 7 } }, - { from: { col: 2, row: 8 }, to: { col: 2, row: 7 } }, - { from: { col: 3, row: 8 }, to: { col: 3, row: 7 } }, - { from: { col: 3, row: 7 }, to: { col: 3, row: 5 } }, - { from: { col: 3, row: 5 }, to: { col: 3, row: 3 } }, - { from: { col: 3, row: 3 }, to: { col: 4, row: 3 } }, - { from: { col: 4, row: 3 }, to: { col: 5, row: 3 } }, - { from: { col: 5, row: 3 }, to: { col: 5, row: 4 } }, - { from: { col: 5, row: 4 }, to: { col: 6, row: 4 } }, - { from: { col: 6, row: 4 }, to: { col: 7, row: 4 } }, - { from: { col: 7, row: 4 }, to: { col: 8, row: 4 } }, - { from: { col: 8, row: 4 }, to: { col: 8, row: 3 } }, - { from: { col: 8, row: 4 }, to: { col: 8, row: 5 } }, - { from: { col: 8, row: 3 }, to: { col: 9, row: 3 } }, - { from: { col: 9, row: 3 }, to: { col: 9, row: 4 } }, - { from: { col: 8, row: 5 }, to: { col: 9, row: 5 } }, - { from: { col: 3, row: 3 }, to: { col: 3, row: 5 } }, - { from: { col: 3, row: 5 }, to: { col: 4, row: 5 } }, - { from: { col: 4, row: 5 }, to: { col: 5, row: 5 } }, - { from: { col: 5, row: 5 }, to: { col: 5, row: 6 } }, - { from: { col: 5, row: 6 }, to: { col: 5, row: 7 } }, - { from: { col: 5, row: 7 }, to: { col: 6, row: 7 } }, - { from: { col: 6, row: 7 }, to: { col: 6, row: 8 } }, - { from: { col: 6, row: 7 }, to: { col: 7, row: 7 } }, - { from: { col: 7, row: 7 }, to: { col: 7, row: 8 } }, +// ── CATENARY CABLE ── +// Real catenary droop: cosh-based sag between two attachment points +function catenarySag(t: number): number { + // t: 0→1 along cable. Returns droop factor: 0 at ends, -1 at center + const u = t * 2 - 1; // -1 → 1 + const cEdge = Math.cosh(2.5); + return -(cEdge - Math.cosh(u * 2.5)) / (cEdge - 1); +} + +function catenaryPoints( + p1: THREE.Vector3, p2: THREE.Vector3, + sag: number, segments: number = 20, +): THREE.Vector3[] { + const pts: THREE.Vector3[] = []; + for (let i = 0; i <= segments; i++) { + const t = i / segments; + pts.push(new THREE.Vector3( + p1.x + (p2.x - p1.x) * t, + p1.y + (p2.y - p1.y) * t + catenarySag(t) * sag, + p1.z + (p2.z - p1.z) * t, + )); + } + return pts; +} + +// ── POISSON DISC SAMPLING (approximate) for natural spacing ── +function poissonDisc( + count: number, radius: number, bounds: number, seed: number, + exclusions: THREE.Vector3[] = [], exRadius = 12, +): THREE.Vector3[] { + const pts: THREE.Vector3[] = []; + const attempts = count * 12; + for (let i = 0; i < attempts && pts.length < count; i++) { + const x = (sr(seed + i * 17) - 0.5) * bounds * 2; + const z = (sr(seed + i * 23 + 1000) - 0.5) * bounds * 2; + if (Math.sqrt(x * x + z * z) > TERRAIN_R * 0.75) continue; + if (pts.some(p => Math.hypot(x - p.x, z - p.z) < radius)) continue; + if (exclusions.some(p => Math.hypot(x - p.x, z - p.z) < exRadius)) continue; + pts.push(onTerrain(x, z)); + } + return pts; +} + +function nearest(pt: THREE.Vector3, list: THREE.Vector3[]): THREE.Vector3 { + let best = list[0], bestD = Infinity; + for (const c of list) { const d = Math.hypot(pt.x - c.x, pt.z - c.z); if (d < bestD) { bestD = d; best = c; } } + return best; +} + +// ═══════════════════════════════════════════════════════════════ +// PROCEDURAL WORLD — all computed from algorithms +// ═══════════════════════════════════════════════════════════════ + +// Step 1: Place hub substations at fixed strategic positions +const HUBS = [onTerrain(-6, 14), onTerrain(50, -2)]; + +// Step 2: Place cities at the periphery +const CITY_DATA = [ + { city: onTerrain(100, -55), sub: onTerrain(84, -46) }, + { city: onTerrain(96, 58), sub: onTerrain(82, 50) }, + { city: onTerrain(-4, -102), sub: onTerrain(-3, -86) }, ]; -export default function HeroIllustration() { - const [isMobile, setIsMobile] = useState(false); +// Step 3: Wind farm zones — use noise to find suitable areas +type WindFarm = { + sub: THREE.Vector3; + turbines: THREE.Vector3[]; + chains: THREE.Vector3[][]; // turbine→turbine→sub rows +}; - useEffect(() => { - const checkMobile = () => setIsMobile(window.innerWidth < 768); - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); +function placeWindFarm( + cx: number, cz: number, + cols: number, rows: number, + spacing: number, + subOffX: number, subOffZ: number, + rotDeg: number, seed: number, +): WindFarm { + const rad = (rotDeg * Math.PI) / 180; + const co = Math.cos(rad), si = Math.sin(rad); + const grid: THREE.Vector3[][] = []; + const turbines: THREE.Vector3[] = []; + + for (let r = 0; r < rows; r++) { + const row: THREE.Vector3[] = []; + for (let c = 0; c < cols; c++) { + const lx = (c - (cols - 1) / 2) * spacing + (sr(seed + r * 10 + c) - 0.5) * 2; + const lz = (r - (rows - 1) / 2) * spacing + (sr(seed + r * 10 + c + 50) - 0.5) * 2; + const wx = cx + lx * co - lz * si; + const wz = cz + lx * si + lz * co; + const pos = onTerrain(wx, wz); + turbines.push(pos); + row.push(pos); + } + grid.push(row); + } + + const sub = onTerrain(cx + subOffX, cz + subOffZ); + + // Chain: each row connects turbine→turbine→sub + const chains: THREE.Vector3[][] = grid.map(row => [...row, sub]); + + return { sub, turbines, chains }; +} + +const WIND_FARMS: WindFarm[] = [ + placeWindFarm(-82, 62, 3, 2, 14, 32, -10, 5, 101), + placeWindFarm(16, 90, 3, 2, 13, -22, -16, 12, 201), + placeWindFarm(-50, -22, 2, 3, 12, 20, 12, -8, 301), + placeWindFarm(70, 52, 3, 2, 12, -24, -14, 3, 401), + placeWindFarm(-100, -8, 2, 2, 14, 22, 8, 0, 501), + placeWindFarm(90, -64, 3, 2, 12, -26, 10, -6, 601), + placeWindFarm(-60, 92, 2, 2, 13, 18, -16, 18, 701), + placeWindFarm(44, -84, 2, 3, 12, -18, 20, 0, 801), +]; + +// Step 4: Solar parks with subs at edge +type SolarPark = { pos: THREE.Vector3; sub: THREE.Vector3; rows: number; cols: number }; + +const SOLAR_PARKS: SolarPark[] = [ + { pos: onTerrain(-114, -56), sub: onTerrain(-98, -48), rows: 6, cols: 8 }, + { pos: onTerrain(-104, -72), sub: onTerrain(-98, -48), rows: 5, cols: 7 }, + { pos: onTerrain(100, 18), sub: onTerrain(88, 12), rows: 5, cols: 7 }, + { pos: onTerrain(-48, -70), sub: onTerrain(-40, -58), rows: 4, cols: 6 }, + { pos: onTerrain(58, -20), sub: onTerrain(52, -14), rows: 5, cols: 6 }, + { pos: onTerrain(-84, 34), sub: onTerrain(-74, 30), rows: 4, cols: 5 }, +]; + +// Deduplicated solar subs +const solarSubSet = new Set(); +const SOLAR_SUBS: THREE.Vector3[] = []; +SOLAR_PARKS.forEach(sp => { + const k = `${sp.sub.x.toFixed(1)},${sp.sub.z.toFixed(1)}`; + if (!solarSubSet.has(k)) { solarSubSet.add(k); SOLAR_SUBS.push(sp.sub); } +}); + +// Step 5: Collect all gen subs and auto-route to hubs +const ALL_GEN_SUBS = [...WIND_FARMS.map(f => f.sub), ...SOLAR_SUBS]; +const ALL_CITY_SUBS = CITY_DATA.map(c => c.sub); +const ALL_SUBS = [...ALL_GEN_SUBS, ...HUBS, ...ALL_CITY_SUBS]; + +// Step 6: HSVL tower routes +const TWR_H = 9; +function lerpRoute(from: THREE.Vector3, to: THREE.Vector3, n: number): THREE.Vector3[] { + const pts = [from]; + for (let i = 1; i <= n; i++) { + const t = i / (n + 1); + pts.push(onTerrain(from.x + (to.x - from.x) * t, from.z + (to.z - from.z) * t)); + } + pts.push(to); + return pts; +} +function autoTowerCount(a: THREE.Vector3, b: THREE.Vector3) { + return Math.max(2, Math.floor(Math.hypot(a.x - b.x, a.z - b.z) / 22)); +} + +const HSVL_ROUTES: THREE.Vector3[][] = []; +ALL_GEN_SUBS.forEach(sub => { + const hub = nearest(sub, HUBS); + HSVL_ROUTES.push(lerpRoute(sub, hub, autoTowerCount(sub, hub))); +}); +HSVL_ROUTES.push(lerpRoute(HUBS[0], HUBS[1], autoTowerCount(HUBS[0], HUBS[1]))); +CITY_DATA.forEach(cd => { + const hub = nearest(cd.sub, HUBS); + HSVL_ROUTES.push(lerpRoute(hub, cd.sub, autoTowerCount(hub, cd.sub))); +}); + +// Step 7: All cable pairs +const DIST_CABLES: [THREE.Vector3, THREE.Vector3][] = [ + ...SOLAR_PARKS.map(sp => [sp.pos, sp.sub] as [THREE.Vector3, THREE.Vector3]), + ...CITY_DATA.map(cd => [cd.sub, cd.city] as [THREE.Vector3, THREE.Vector3]), +]; + +// Step 8: All infra for exclusion +const ALL_INFRA: THREE.Vector3[] = [ + ...ALL_SUBS, + ...CITY_DATA.map(c => c.city), + ...WIND_FARMS.flatMap(f => f.turbines), + ...SOLAR_PARKS.map(s => s.pos), +]; + +// Step 9: Poisson-disc distributed forests +const FOREST_POSITIONS = poissonDisc(55, 18, 140, 5000, ALL_INFRA, 14); + +// ═══════════════════════════════════════════════════════════════ +// TERRAIN MESH — uses the same terrainY() function +// ═══════════════════════════════════════════════════════════════ +const Terrain = () => { + const geometry = useMemo(() => { + const geo = new THREE.CircleGeometry(TERRAIN_R, 200); + const pos = geo.attributes.position; + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i); + const z = -pos.getY(i); + pos.setX(i, x); + pos.setY(i, terrainY(x, z)); + pos.setZ(i, z); + } + geo.computeVertexNormals(); + return geo; }, []); + return ( + + + + + ); +}; - const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100'; - // Increase scale slightly and opacity significantly on mobile to fix the "thin" appearance - const scale = isMobile ? 1.6 : 1; - const opacity = isMobile ? 0.9 : 0.85; +// ═══════════════════════════════════════════════════════════════ +// COMPONENTS +// ═══════════════════════════════════════════════════════════════ + +const SolarField = ({ position, rows = 5, cols = 7 }: { position: THREE.Vector3; rows?: number; cols?: number }) => ( + + {Array.from({ length: rows }).map((_, r) => + Array.from({ length: cols }).map((_, c) => ( + + + + + + + + )) + )} + +); + +const TH = 6, BLade = 3; +const WindTurbine = ({ position, seed = 0 }: { position: THREE.Vector3; seed?: number }) => { + const bladesRef = useRef(null); + const speed = 1.8 + sr(seed * 7) * 1.2; + useFrame((s) => { if (bladesRef.current) bladesRef.current.rotation.z = s.clock.elapsedTime * speed; }); + return ( + + + + + + {[0, 1, 2].map(i => ( + + + + ))} + + + ); +}; + +const Substation = ({ position, size = 'small' }: { position: THREE.Vector3; size?: 'small' | 'large' }) => { + const s = size === 'large' ? 1.3 : 0.8; + const pulseRef = useRef(null); + useFrame((st) => { if (pulseRef.current) pulseRef.current.scale.setScalar(1 + Math.sin(st.clock.elapsedTime * 2.5) * 0.15); }); + return ( + + + + {[[-0.8 * s, -0.5 * s], [0.8 * s, -0.5 * s], [0, 0.6 * s]].map(([x, z], i) => ( + + + + + + ))} + + + + ); +}; + +const City = ({ position, seed = 0 }: { position: THREE.Vector3; seed?: number }) => { + const buildings = useMemo(() => { + const items = []; + const count = 10 + Math.floor(sr(seed * 3) * 8); + for (let i = 0; i < count; i++) { + const angle = sr(seed * 100 + i * 17) * Math.PI * 2; + const radius = 0.5 + sr(seed * 100 + i * 29) * 6; + const height = 1 + sr(seed * 100 + i * 37) * 4; + items.push({ + x: Math.cos(angle) * radius, z: Math.sin(angle) * radius, + height, width: 0.4 + sr(seed * 100 + i * 43) * 0.8, + depth: 0.4 + sr(seed * 100 + i * 47) * 0.6, + rows: Math.max(1, Math.floor(height / 0.8)), + }); + } + return items; + }, [seed]); return ( -
- -
+ // Distribution cables: solar→sub, sub→city (elevated) + DIST_CABLES.forEach(([from, to]) => { + const a = from.clone(); a.y += 5; + const b = to.clone(); b.y += 5; + result.push({ + points: catenaryPoints(a, b, 1.5, 16), + color: '#55cc88', width: 1.2, opacity: 0.35, + }); + }); + + // Wind farm internal chains: ground-level cables + WIND_FARMS.forEach(farm => { + farm.chains.forEach(chain => { + for (let i = 1; i < chain.length; i++) { + const a = chain[i - 1].clone(); a.y += 0.3; + const b = chain[i].clone(); b.y += 0.3; + result.push({ + points: catenaryPoints(a, b, 0.3, 8), + color: '#30ff70', width: 1, opacity: 0.2, + }); + } + }); + }); + + return result; + }, []); + + // Towers at intermediate route points + const towers = useMemo(() => { + const pts: THREE.Vector3[] = []; + HSVL_ROUTES.forEach(route => { + for (let i = 1; i < route.length - 1; i++) pts.push(route[i]); + }); + return pts; + }, []); + + return ( + + {towers.map((pos, i) => )} + {cables.map((c, i) => ( + + ))} + + ); +}; + +// ═══════════════════════════════════════════════════════════════ +// FOREST — Poisson-disc distributed, terrain-aware +// ═══════════════════════════════════════════════════════════════ +const Forest = ({ position, seed = 0, count = 30 }: { position: THREE.Vector3; seed?: number; count?: number }) => { + const trees = useMemo(() => { + const items = []; + for (let i = 0; i < count; i++) { + // Use noise for natural clumping + const angle = sr(seed * 200 + i * 11) * Math.PI * 2; + const radius = sr(seed * 200 + i * 23) * 14; + const wx = position.x + Math.cos(angle) * radius; + const wz = position.z + Math.sin(angle) * radius; + // Skip if near infrastructure + if (ALL_INFRA.some(p => Math.hypot(wx - p.x, wz - p.z) < 10)) continue; + // Skip if too far from terrain center + if (Math.sqrt(wx * wx + wz * wz) > TERRAIN_R * 0.72) continue; + + const localY = terrainY(wx, wz) - position.y; // height relative to parent group + const trunkH = 0.4 + sr(seed * 200 + i * 31) * 1; + const canopyH = 0.6 + sr(seed * 200 + i * 37) * 1.5; + const canopyR = 0.2 + sr(seed * 200 + i * 43) * 0.4; + const shade = sr(seed * 200 + i * 47); + items.push({ + x: Math.cos(angle) * radius, y: localY, z: Math.sin(angle) * radius, + trunkH, canopyH, canopyR, shade, + brightness: 0.2 + sr(seed * 200 + i * 41) * 0.3, + }); + } + return items; + }, [seed, count, position]); + + return ( + + {trees.map((t, i) => ( + + + 0.6 ? '#15aa45' : t.shade > 0.3 ? '#1a9050' : '#10804a'} transparent opacity={t.brightness} toneMapped={false} /> + 0.5 ? '#20cc55' : '#18bb50'} transparent opacity={t.brightness * 0.7} toneMapped={false} /> + + ))} + + ); +}; + +// ═══════════════════════════════════════════════════════════════ +// ENERGY PARTICLES — flow along catenary cables +// ═══════════════════════════════════════════════════════════════ +const EnergyParticles = () => { + const meshRef = useRef(null); + const dummy = useMemo(() => new THREE.Object3D(), []); + + // Build all curves from cable segments for particle paths + const curves = useMemo(() => { + const all: THREE.CatmullRomCurve3[] = []; + // HSVL + HSVL_ROUTES.forEach(route => { + for (let i = 1; i < route.length; i++) { + const a = route[i - 1].clone(); a.y += TWR_H; + const b = route[i].clone(); b.y += TWR_H; + all.push(new THREE.CatmullRomCurve3(catenaryPoints(a, b, 2.5, 12))); + } + }); + // Distribution + DIST_CABLES.forEach(([from, to]) => { + const a = from.clone(); a.y += 5; + const b = to.clone(); b.y += 5; + all.push(new THREE.CatmullRomCurve3(catenaryPoints(a, b, 1.5, 10))); + }); + // Farm chains + WIND_FARMS.forEach(farm => { + farm.chains.forEach(chain => { + for (let i = 1; i < chain.length; i++) { + const a = chain[i - 1].clone(); a.y += 0.3; + const b = chain[i].clone(); b.y += 0.3; + all.push(new THREE.CatmullRomCurve3(catenaryPoints(a, b, 0.3, 6))); + } + }); + }); + return all; + }, []); + + const perCurve = 4; + const total = curves.length * perCurve; + const data = useMemo(() => { + const items = []; + for (let c = 0; c < curves.length; c++) + for (let p = 0; p < perCurve; p++) + items.push({ curve: c, t: p / perCurve, speed: 0.3 + sr(c * 100 + p * 7) * 0.35, phase: sr(c * 100 + p * 13) * Math.PI * 2 }); + return items; + }, [curves]); + + useFrame((state, delta) => { + if (!meshRef.current) return; + data.forEach((d, i) => { + d.t += delta * d.speed; if (d.t > 1) d.t -= 1; + const p = curves[d.curve].getPointAt(d.t); + dummy.position.copy(p); + dummy.scale.setScalar(0.5 + Math.sin(state.clock.elapsedTime * 6 + d.phase) * 0.3); + dummy.updateMatrix(); + meshRef.current!.setMatrixAt(i, dummy.matrix); + }); + meshRef.current.instanceMatrix.needsUpdate = true; + }); + + return ( + + + + + ); +}; + +// ═══════════════════════════════════════════════════════════════ +// AMBIENT MOTES +// ═══════════════════════════════════════════════════════════════ +const AmbientMotes = () => { + const meshRef = useRef(null); + const count = 250; + const dummy = useMemo(() => new THREE.Object3D(), []); + const motes = useMemo(() => Array.from({ length: count }, (_, i) => ({ + x: (sr(i * 7 + 1) - 0.5) * 220, y: 3 + sr(i * 13 + 2) * 18, z: (sr(i * 19 + 3) - 0.5) * 220, + speed: 0.1 + sr(i * 23 + 4) * 0.3, phase: sr(i * 29 + 5) * Math.PI * 2, + })), []); + + useFrame((state) => { + if (!meshRef.current) return; + motes.forEach((m, i) => { + dummy.position.set(m.x + Math.sin(state.clock.elapsedTime * m.speed + m.phase) * 3, m.y + Math.sin(state.clock.elapsedTime * m.speed * 0.7 + m.phase) * 2, m.z + Math.cos(state.clock.elapsedTime * m.speed + m.phase) * 3); + dummy.scale.setScalar(0.3 + Math.sin(state.clock.elapsedTime * 2 + m.phase) * 0.2); + dummy.updateMatrix(); meshRef.current!.setMatrixAt(i, dummy.matrix); + }); + meshRef.current.instanceMatrix.needsUpdate = true; + }); + + return ( + + + + ); +}; + +// ═══════════════════════════════════════════════════════════════ +// SCENE +// ═══════════════════════════════════════════════════════════════ +const Scene = () => ( + <> + + + + + + + + + + + + {/* Solar parks */} + {SOLAR_PARKS.map((sp, i) => )} + + {/* Wind farms */} + {WIND_FARMS.flatMap((farm, fi) => + farm.turbines.map((pos, ti) => ) + )} + + {/* Substations */} + {WIND_FARMS.map((f, i) => )} + {SOLAR_SUBS.map((s, i) => )} + {HUBS.map((h, i) => )} + {CITY_DATA.map((cd, i) => )} + + {/* Cities */} + {CITY_DATA.map((cd, i) => )} + + {/* All cables with catenary physics */} + + + {/* Forests — Poisson-disc placed */} + {FOREST_POSITIONS.map((pos, i) => ( + + ))} + + {/* Energy flow */} + + + + + + + +); + +// ═══════════════════════════════════════════════════════════════ +export default function HeroIllustration() { + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect + if (!mounted) return null; + return ( +
+ + + +
); } diff --git a/package.json b/package.json index 4343d190..d60d40e5 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "private": true, "packageManager": "pnpm@10.18.3", "dependencies": { - "@mintel/mail": "^1.8.21", - "@mintel/next-config": "^1.8.21", - "@mintel/next-feedback": "^1.8.21", - "@mintel/next-utils": "^1.8.21", + "@mintel/mail": "^1.8.20", + "@mintel/next-config": "^1.8.20", + "@mintel/next-feedback": "^1.8.20", + "@mintel/next-utils": "^1.8.20", "@payloadcms/db-postgres": "^3.77.0", "@payloadcms/email-nodemailer": "^3.77.0", "@payloadcms/next": "^3.77.0", @@ -15,6 +15,9 @@ "@payloadcms/ui": "^3.77.0", "@react-email/components": "^1.0.7", "@react-pdf/renderer": "^4.3.2", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "@sentry/nextjs": "^10.39.0", "@types/recharts": "^2.0.1", "axios": "^1.13.5", @@ -45,6 +48,7 @@ "sharp": "^0.34.5", "svg-to-pdfkit": "^0.1.8", "tailwind-merge": "^3.4.0", + "three": "^0.183.1", "xlsx": "npm:@e965/xlsx@^0.20.3", "zod": "3.25.76" }, @@ -53,8 +57,8 @@ "@commitlint/config-conventional": "^20.4.0", "@cspell/dict-de-de": "^4.1.2", "@lhci/cli": "^0.15.1", - "@mintel/eslint-config": "^1.8.21", - "@mintel/tsconfig": "^1.8.21", + "@mintel/eslint-config": "^1.8.20", + "@mintel/tsconfig": "^1.8.20", "@next/bundle-analyzer": "^16.1.6", "@tailwindcss/cli": "^4.1.18", "@tailwindcss/postcss": "^4.1.18", @@ -65,6 +69,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/sharp": "^0.31.1", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/ui": "^4.0.16", "autoprefixer": "^10.4.23",