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",