Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m15s
Build & Deploy / 🏗️ Build (push) Successful in 3m33s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m15s
Build & Deploy / 🔔 Notify (push) Successful in 2s
1112 lines
38 KiB
TypeScript
1112 lines
38 KiB
TypeScript
/* eslint-disable react/no-unknown-property */
|
|
'use client';
|
|
|
|
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';
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// CORE ALGORITHMS
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// Deterministic hash
|
|
function sr(seed: number) {
|
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
}
|
|
|
|
// ── TERRAIN HEIGHT FUNCTION ──
|
|
// SINGLE SOURCE OF TRUTH for terrain elevation.
|
|
const TERRAIN_R = 400;
|
|
const TERRAIN_CURVATURE = 0.00012; // planetary curvature
|
|
|
|
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.2);
|
|
|
|
// Planetary curvature — drops off like the surface of a sphere
|
|
const curvature = -dist * dist * TERRAIN_CURVATURE;
|
|
|
|
// Multi-octave noise for rolling hills
|
|
let y = 0;
|
|
y += Math.sin(x * 0.025 + 1.2) * Math.cos(z * 0.02 + 0.8) * 3.5 * h;
|
|
y += Math.sin(x * 0.05 + 3.0) * Math.cos(z * 0.04 + 2.0) * 1.8 * h;
|
|
y += Math.sin(x * 0.1 + 5.0) * Math.cos(z * 0.08 + 4.0) * 0.6 * h;
|
|
y += Math.sin(x * 0.015) * Math.cos(z * 0.018 + 6.0) * 4.0 * h;
|
|
|
|
// Edge drop-off
|
|
y -= edge * edge * 40;
|
|
|
|
return y + curvature;
|
|
}
|
|
|
|
function onTerrain(x: number, z: number): THREE.Vector3 {
|
|
return new THREE.Vector3(x, terrainY(x, z), z);
|
|
}
|
|
|
|
// ── CATENARY CABLE ──
|
|
function catenarySag(t: number): number {
|
|
const u = t * 2 - 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 ──
|
|
function poissonDisc(
|
|
count: number, radius: number, bounds: number, seed: number,
|
|
exclusions: THREE.Vector3[] = [], exRadius = 18,
|
|
): THREE.Vector3[] {
|
|
const pts: THREE.Vector3[] = [];
|
|
const attempts = count * 15;
|
|
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.7) 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
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// Hub substations — central distribution points
|
|
const HUBS = [onTerrain(-10, 20), onTerrain(60, -5)];
|
|
|
|
// Cities at the periphery with their substations
|
|
const CITY_DATA = [
|
|
{ city: onTerrain(150, -80), sub: onTerrain(125, -65) },
|
|
{ city: onTerrain(140, 85), sub: onTerrain(115, 70) },
|
|
{ city: onTerrain(-15, -150), sub: onTerrain(-12, -125) },
|
|
{ city: onTerrain(-140, 40), sub: onTerrain(-115, 35) },
|
|
{ city: onTerrain(30, 160), sub: onTerrain(28, 135) },
|
|
];
|
|
|
|
// Wind farm generation
|
|
type WindFarm = {
|
|
sub: THREE.Vector3;
|
|
turbines: THREE.Vector3[];
|
|
chains: THREE.Vector3[][];
|
|
};
|
|
|
|
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) * 3;
|
|
const lz = (r - (rows - 1) / 2) * spacing + (sr(seed + r * 10 + c + 50) - 0.5) * 3;
|
|
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);
|
|
const chains: THREE.Vector3[][] = grid.map(row => [...row, sub]);
|
|
|
|
return { sub, turbines, chains };
|
|
}
|
|
|
|
const WIND_FARMS: WindFarm[] = [
|
|
placeWindFarm(-120, 90, 4, 3, 16, 40, -15, 5, 101),
|
|
placeWindFarm(25, 130, 3, 3, 15, -28, -20, 12, 201),
|
|
placeWindFarm(-75, -35, 3, 3, 14, 28, 18, -8, 301),
|
|
placeWindFarm(100, 75, 4, 2, 14, -30, -18, 3, 401),
|
|
placeWindFarm(-150, -15, 3, 2, 16, 28, 10, 0, 501),
|
|
placeWindFarm(130, -90, 3, 3, 14, -32, 15, -6, 601),
|
|
placeWindFarm(-90, 140, 3, 2, 15, 24, -20, 18, 701),
|
|
placeWindFarm(65, -120, 3, 3, 14, -22, 25, 0, 801),
|
|
placeWindFarm(-40, 60, 2, 3, 14, 20, -16, 10, 901),
|
|
placeWindFarm(170, 20, 3, 2, 15, -30, 12, -3, 1001),
|
|
];
|
|
|
|
// Solar parks with substations at edge
|
|
type SolarPark = { pos: THREE.Vector3; sub: THREE.Vector3; rows: number; cols: number };
|
|
|
|
const SOLAR_PARKS: SolarPark[] = [
|
|
{ pos: onTerrain(-170, -80), sub: onTerrain(-145, -70), rows: 7, cols: 10 },
|
|
{ pos: onTerrain(-155, -105), sub: onTerrain(-145, -70), rows: 6, cols: 8 },
|
|
{ pos: onTerrain(145, 28), sub: onTerrain(130, 20), rows: 6, cols: 9 },
|
|
{ pos: onTerrain(-70, -100), sub: onTerrain(-58, -85), rows: 5, cols: 7 },
|
|
{ pos: onTerrain(85, -30), sub: onTerrain(75, -20), rows: 6, cols: 8 },
|
|
{ pos: onTerrain(-125, 50), sub: onTerrain(-110, 42), rows: 5, cols: 6 },
|
|
{ pos: onTerrain(50, -170), sub: onTerrain(48, -148), rows: 5, cols: 7 },
|
|
{ pos: onTerrain(-30, -160), sub: onTerrain(-25, -138), rows: 6, cols: 8 },
|
|
];
|
|
|
|
// Deduplicated solar subs
|
|
const solarSubSet = new Set<string>();
|
|
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); }
|
|
});
|
|
|
|
// Collect all generation 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];
|
|
|
|
// Tower routing
|
|
const TWR_H = 12;
|
|
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) / 28));
|
|
}
|
|
|
|
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)));
|
|
});
|
|
|
|
// Distribution cables: solar→sub, sub→city
|
|
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]),
|
|
];
|
|
|
|
// All infrastructure for exclusion zones
|
|
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),
|
|
];
|
|
|
|
// Forest positions via Poisson disc
|
|
const FOREST_POSITIONS = poissonDisc(80, 22, 220, 5000, ALL_INFRA, 18);
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// TERRAIN MESH
|
|
// ═══════════════════════════════════════════════════════════════
|
|
const Terrain = () => {
|
|
const geometry = useMemo(() => {
|
|
const geo = new THREE.CircleGeometry(TERRAIN_R, 256);
|
|
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 (
|
|
<group>
|
|
<mesh geometry={geometry}>
|
|
<meshStandardMaterial
|
|
color="#010a18"
|
|
roughness={0.92}
|
|
metalness={0.08}
|
|
envMapIntensity={0.3}
|
|
/>
|
|
</mesh>
|
|
<mesh geometry={geometry} position={[0, 0.08, 0]}>
|
|
<meshBasicMaterial
|
|
color="#0a3060"
|
|
wireframe
|
|
transparent
|
|
opacity={0.06}
|
|
/>
|
|
</mesh>
|
|
{/* Subtle grid glow on terrain */}
|
|
<mesh geometry={geometry} position={[0, 0.12, 0]}>
|
|
<meshBasicMaterial
|
|
color="#0055aa"
|
|
wireframe
|
|
transparent
|
|
opacity={0.03}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// COMPONENTS
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ── SOLAR FIELD ──
|
|
const SolarField = ({ position, rows = 5, cols = 7 }: { position: THREE.Vector3; rows?: number; cols?: number }) => (
|
|
<group position={position}>
|
|
{Array.from({ length: rows }).map((_, r) =>
|
|
Array.from({ length: cols }).map((_, c) => (
|
|
<group key={`${r}-${c}`} position={[c * 2.5 - (cols * 2.5) / 2, 0, r * 2.2 - (rows * 2.2) / 2]}>
|
|
{/* Post */}
|
|
<mesh position={[0, 0.4, 0]}>
|
|
<cylinderGeometry args={[0.025, 0.025, 0.8, 4]} />
|
|
<meshBasicMaterial color="#1a4a70" transparent opacity={0.4} />
|
|
</mesh>
|
|
{/* Panel */}
|
|
<group position={[0, 0.8, 0]} rotation={[-Math.PI / 4.5, 0, 0]}>
|
|
<mesh>
|
|
<boxGeometry args={[2, 1.2, 0.04]} />
|
|
<meshStandardMaterial
|
|
color="#060e22"
|
|
emissive="#003366"
|
|
emissiveIntensity={0.5}
|
|
metalness={0.95}
|
|
roughness={0.05}
|
|
/>
|
|
</mesh>
|
|
{/* Grid lines on panel */}
|
|
<mesh position={[0, 0, 0.025]}>
|
|
<boxGeometry args={[2, 1.2, 0.01]} />
|
|
<meshBasicMaterial
|
|
color="#2288cc"
|
|
wireframe
|
|
transparent
|
|
opacity={0.12}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Subtle blue reflection shine */}
|
|
<mesh position={[0, 0, 0.03]}>
|
|
<planeGeometry args={[1.8, 0.3]} />
|
|
<meshBasicMaterial
|
|
color="#4488ff"
|
|
transparent
|
|
opacity={0.08}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
</group>
|
|
))
|
|
)}
|
|
</group>
|
|
);
|
|
|
|
// ── WIND TURBINE ──
|
|
const TURBINE_H = 8;
|
|
const BLADE_L = 4;
|
|
|
|
const WindTurbine = ({ position, seed = 0 }: { position: THREE.Vector3; seed?: number }) => {
|
|
const bladesRef = useRef<THREE.Group>(null);
|
|
const speed = 1.5 + sr(seed * 7) * 1.5;
|
|
useFrame((s) => {
|
|
if (bladesRef.current) bladesRef.current.rotation.z = s.clock.elapsedTime * speed;
|
|
});
|
|
|
|
return (
|
|
<group position={position}>
|
|
{/* Tower — tapered */}
|
|
<mesh position={[0, TURBINE_H / 2, 0]}>
|
|
<cylinderGeometry args={[0.08, 0.18, TURBINE_H, 6]} />
|
|
<meshStandardMaterial
|
|
color="#88bbdd"
|
|
emissive="#2266aa"
|
|
emissiveIntensity={0.15}
|
|
metalness={0.7}
|
|
roughness={0.3}
|
|
transparent
|
|
opacity={0.7}
|
|
/>
|
|
</mesh>
|
|
{/* Nacelle */}
|
|
<mesh position={[0, TURBINE_H, -0.18]}>
|
|
<boxGeometry args={[0.25, 0.22, 0.5]} />
|
|
<meshStandardMaterial
|
|
color="#aaddee"
|
|
emissive="#3388bb"
|
|
emissiveIntensity={0.2}
|
|
metalness={0.6}
|
|
roughness={0.4}
|
|
transparent
|
|
opacity={0.7}
|
|
/>
|
|
</mesh>
|
|
{/* Rotor hub + blades */}
|
|
<group position={[0, TURBINE_H, 0.08]} ref={bladesRef}>
|
|
<mesh>
|
|
<sphereGeometry args={[0.12, 8, 8]} />
|
|
<meshBasicMaterial color="#aaeeff" toneMapped={false} />
|
|
</mesh>
|
|
{[0, 1, 2].map(i => (
|
|
<group key={i} rotation={[0, 0, (i * Math.PI * 2) / 3]}>
|
|
<mesh position={[0, BLADE_L / 2 + 0.12, 0]}>
|
|
<boxGeometry args={[0.14, BLADE_L, 0.025]} />
|
|
<meshBasicMaterial
|
|
color="#88ddff"
|
|
transparent
|
|
opacity={0.5}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
))}
|
|
</group>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// ── SUBSTATION ──
|
|
const Substation = ({ position, size = 'small' }: { position: THREE.Vector3; size?: 'small' | 'large' }) => {
|
|
const s = size === 'large' ? 1.5 : 0.9;
|
|
const pulseRef = useRef<THREE.Mesh>(null);
|
|
useFrame((st) => {
|
|
if (pulseRef.current) {
|
|
const pulse = 1 + Math.sin(st.clock.elapsedTime * 3) * 0.2;
|
|
pulseRef.current.scale.setScalar(pulse);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<group position={position}>
|
|
{/* Fenced area */}
|
|
<mesh position={[0, 0.9 * s, 0]}>
|
|
<boxGeometry args={[5 * s, 1.8 * s, 3.5 * s]} />
|
|
<meshBasicMaterial
|
|
color="#2288aa"
|
|
wireframe
|
|
transparent
|
|
opacity={0.15}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Ground pad */}
|
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.05, 0]}>
|
|
<planeGeometry args={[5 * s, 3.5 * s]} />
|
|
<meshStandardMaterial
|
|
color="#0a1825"
|
|
roughness={0.9}
|
|
metalness={0.1}
|
|
/>
|
|
</mesh>
|
|
{/* Transformers */}
|
|
{[[-1 * s, -0.6 * s], [1 * s, -0.6 * s], [0, 0.7 * s]].map(([x, z], i) => (
|
|
<group key={i} position={[x, 0, z]}>
|
|
<mesh position={[0, 0.55 * s, 0]}>
|
|
<boxGeometry args={[0.9 * s, 1.1 * s, 0.7 * s]} />
|
|
<meshStandardMaterial
|
|
color="#0c1e35"
|
|
emissive="#0a2040"
|
|
emissiveIntensity={0.4}
|
|
metalness={0.7}
|
|
roughness={0.3}
|
|
/>
|
|
</mesh>
|
|
{/* Wireframe overlay */}
|
|
<mesh position={[0, 0.55 * s, 0]}>
|
|
<boxGeometry args={[0.95 * s, 1.15 * s, 0.75 * s]} />
|
|
<meshBasicMaterial
|
|
color="#3399bb"
|
|
wireframe
|
|
transparent
|
|
opacity={0.12}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Insulator */}
|
|
<mesh position={[0, 1.2 * s, 0]}>
|
|
<cylinderGeometry args={[0.04 * s, 0.07 * s, 0.35 * s, 4]} />
|
|
<meshBasicMaterial
|
|
color="#66ccee"
|
|
transparent
|
|
opacity={0.6}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
))}
|
|
{/* Bus bar */}
|
|
<mesh position={[0, 1.3 * s, -0.5 * s]} rotation={[0, 0, Math.PI / 2]}>
|
|
<cylinderGeometry args={[0.03, 0.03, 3 * s, 4]} />
|
|
<meshBasicMaterial
|
|
color="#55ffaa"
|
|
transparent
|
|
opacity={0.4}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Pulsing energy indicator */}
|
|
<mesh position={[0, 1.8 * s, 0]} ref={pulseRef}>
|
|
<sphereGeometry args={[0.25 * s, 10, 10]} />
|
|
<meshBasicMaterial color="#50ff88" toneMapped={false} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// ── CITY ──
|
|
const City = ({ position, seed = 0 }: { position: THREE.Vector3; seed?: number }) => {
|
|
const buildings = useMemo(() => {
|
|
const items = [];
|
|
const count = 14 + Math.floor(sr(seed * 3) * 10);
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = sr(seed * 100 + i * 17) * Math.PI * 2;
|
|
const radius = 0.8 + sr(seed * 100 + i * 29) * 8;
|
|
const height = 1.2 + sr(seed * 100 + i * 37) * 5;
|
|
items.push({
|
|
x: Math.cos(angle) * radius,
|
|
z: Math.sin(angle) * radius,
|
|
height,
|
|
width: 0.5 + sr(seed * 100 + i * 43) * 1,
|
|
depth: 0.5 + sr(seed * 100 + i * 47) * 0.8,
|
|
rows: Math.max(1, Math.floor(height / 0.8)),
|
|
});
|
|
}
|
|
return items;
|
|
}, [seed]);
|
|
|
|
return (
|
|
<group position={position}>
|
|
{buildings.map((b, i) => (
|
|
<group key={i} position={[b.x, 0, b.z]}>
|
|
{/* Building body */}
|
|
<mesh position={[0, b.height / 2, 0]}>
|
|
<boxGeometry args={[b.width, b.height, b.depth]} />
|
|
<meshStandardMaterial
|
|
color="#030810"
|
|
emissive="#0a1830"
|
|
emissiveIntensity={0.2}
|
|
metalness={0.9}
|
|
roughness={0.1}
|
|
/>
|
|
</mesh>
|
|
{/* Building edge glow */}
|
|
<mesh position={[0, b.height / 2, 0]}>
|
|
<boxGeometry args={[b.width + 0.02, b.height + 0.02, b.depth + 0.02]} />
|
|
<meshBasicMaterial
|
|
color="#1144aa"
|
|
wireframe
|
|
transparent
|
|
opacity={0.06}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Windows — glowing */}
|
|
{Array.from({ length: b.rows }).map((_, row) => {
|
|
const wc = Math.max(1, Math.floor(b.width / 0.35));
|
|
return Array.from({ length: wc }).map((_, col) => {
|
|
if (sr(seed * 1000 + i * 100 + row * 10 + col) < 0.2) return null;
|
|
const isWarm = sr(seed * 2000 + i * 50 + row + col) > 0.4;
|
|
const color = isWarm ? '#ffcc33' : '#88ccff';
|
|
const intensity = 0.6 + sr(seed * 3000 + i * 30 + row * 5 + col) * 0.4;
|
|
const xP = wc > 1 ? (col / (wc - 1) - 0.5) * (b.width * 0.7) : 0;
|
|
return (
|
|
<group key={`w-${row}-${col}`}>
|
|
<mesh position={[xP, 0.4 + row * 0.8, b.depth / 2 + 0.01]}>
|
|
<planeGeometry args={[0.2, 0.26]} />
|
|
<meshBasicMaterial
|
|
color={color}
|
|
transparent
|
|
opacity={intensity}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
<mesh position={[xP, 0.4 + row * 0.8, -b.depth / 2 - 0.01]} rotation={[0, Math.PI, 0]}>
|
|
<planeGeometry args={[0.2, 0.26]} />
|
|
<meshBasicMaterial
|
|
color={color}
|
|
transparent
|
|
opacity={intensity * 0.8}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
);
|
|
});
|
|
})}
|
|
{/* Rooftop light */}
|
|
<mesh position={[0, b.height + 0.1, 0]}>
|
|
<sphereGeometry args={[0.06, 4, 4]} />
|
|
<meshBasicMaterial
|
|
color="#ff3333"
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
))}
|
|
{/* City ambient glow */}
|
|
<pointLight
|
|
position={[0, 6, 0]}
|
|
intensity={10}
|
|
color="#ffaa44"
|
|
distance={100}
|
|
decay={2}
|
|
/>
|
|
<pointLight
|
|
position={[0, 2, 0]}
|
|
intensity={5}
|
|
color="#ffdd88"
|
|
distance={40}
|
|
decay={2}
|
|
/>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// TRANSMISSION TOWER — lattice-style pylon
|
|
// ═══════════════════════════════════════════════════════════════
|
|
const TransmissionTower = ({ position }: { position: THREE.Vector3 }) => (
|
|
<group position={position}>
|
|
{/* Main tower body — tapered */}
|
|
<mesh position={[0, TWR_H / 2, 0]}>
|
|
<cylinderGeometry args={[0.18, 0.7, TWR_H, 4]} />
|
|
<meshBasicMaterial
|
|
color="#5599cc"
|
|
transparent
|
|
opacity={0.45}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Wireframe lattice overlay */}
|
|
<mesh position={[0, TWR_H / 2, 0]}>
|
|
<cylinderGeometry args={[0.22, 0.75, TWR_H, 4]} />
|
|
<meshBasicMaterial
|
|
color="#4488bb"
|
|
wireframe
|
|
transparent
|
|
opacity={0.18}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Top cross-arm */}
|
|
<mesh position={[0, TWR_H, 0]} rotation={[0, 0, Math.PI / 2]}>
|
|
<cylinderGeometry args={[0.05, 0.05, 4.5, 4]} />
|
|
<meshBasicMaterial
|
|
color="#6699cc"
|
|
transparent
|
|
opacity={0.5}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Middle cross-arm */}
|
|
<mesh position={[0, TWR_H * 0.72, 0]} rotation={[0, 0, Math.PI / 2]}>
|
|
<cylinderGeometry args={[0.04, 0.04, 3.2, 4]} />
|
|
<meshBasicMaterial
|
|
color="#5588aa"
|
|
transparent
|
|
opacity={0.35}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Insulator tips */}
|
|
{[-2, -0.8, 0.8, 2].map((xOff, i) => (
|
|
<mesh key={i} position={[xOff, TWR_H - 0.3, 0]}>
|
|
<cylinderGeometry args={[0.02, 0.05, 0.5, 4]} />
|
|
<meshBasicMaterial
|
|
color="#88ccee"
|
|
transparent
|
|
opacity={0.45}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
))}
|
|
{/* Aviation light */}
|
|
<mesh position={[0, TWR_H + 0.4, 0]}>
|
|
<sphereGeometry args={[0.12, 6, 6]} />
|
|
<meshBasicMaterial
|
|
color="#ff3333"
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
);
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// CABLE NETWORK
|
|
// ═══════════════════════════════════════════════════════════════
|
|
const CableNetwork = () => {
|
|
const cables = useMemo(() => {
|
|
const result: { points: THREE.Vector3[]; color: string; width: number; opacity: number }[] = [];
|
|
|
|
// HSVL cables: tower-top to tower-top with catenary sag
|
|
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;
|
|
result.push({
|
|
points: catenaryPoints(a, b, 3, 24),
|
|
color: '#5599cc', width: 1.5, opacity: 0.35,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Distribution cables
|
|
DIST_CABLES.forEach(([from, to]) => {
|
|
const a = from.clone(); a.y += 6;
|
|
const b = to.clone(); b.y += 6;
|
|
result.push({
|
|
points: catenaryPoints(a, b, 2, 18),
|
|
color: '#44bb88', width: 1.2, opacity: 0.3,
|
|
});
|
|
});
|
|
|
|
// Wind farm internal 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.4;
|
|
const b = chain[i].clone(); b.y += 0.4;
|
|
result.push({
|
|
points: catenaryPoints(a, b, 0.4, 10),
|
|
color: '#30ee70', width: 0.8, opacity: 0.18,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}, []);
|
|
|
|
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 (
|
|
<group>
|
|
{towers.map((pos, i) => <TransmissionTower key={`t-${i}`} position={pos} />)}
|
|
{cables.map((c, i) => (
|
|
<Line
|
|
key={`c-${i}`}
|
|
points={c.points}
|
|
color={c.color}
|
|
lineWidth={c.width}
|
|
transparent
|
|
opacity={c.opacity}
|
|
/>
|
|
))}
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// FOREST
|
|
// ═══════════════════════════════════════════════════════════════
|
|
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++) {
|
|
const angle = sr(seed * 200 + i * 11) * Math.PI * 2;
|
|
const radius = sr(seed * 200 + i * 23) * 18;
|
|
const wx = position.x + Math.cos(angle) * radius;
|
|
const wz = position.z + Math.sin(angle) * radius;
|
|
if (ALL_INFRA.some(p => Math.hypot(wx - p.x, wz - p.z) < 12)) continue;
|
|
if (Math.sqrt(wx * wx + wz * wz) > TERRAIN_R * 0.68) continue;
|
|
|
|
const localY = terrainY(wx, wz) - position.y;
|
|
const trunkH = 0.5 + sr(seed * 200 + i * 31) * 1.2;
|
|
const canopyH = 0.8 + sr(seed * 200 + i * 37) * 2;
|
|
const canopyR = 0.25 + sr(seed * 200 + i * 43) * 0.5;
|
|
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.15 + sr(seed * 200 + i * 41) * 0.25,
|
|
});
|
|
}
|
|
return items;
|
|
}, [seed, count, position]);
|
|
|
|
return (
|
|
<group position={position}>
|
|
{trees.map((t, i) => (
|
|
<group key={i} position={[t.x, t.y, t.z]}>
|
|
{/* Trunk */}
|
|
<mesh position={[0, t.trunkH / 2, 0]}>
|
|
<cylinderGeometry args={[0.035, 0.07, t.trunkH, 4]} />
|
|
<meshBasicMaterial
|
|
color="#0f2a1e"
|
|
transparent
|
|
opacity={0.5}
|
|
/>
|
|
</mesh>
|
|
{/* Lower canopy */}
|
|
<mesh position={[0, t.trunkH + t.canopyH / 2, 0]}>
|
|
<coneGeometry args={[t.canopyR, t.canopyH, 5]} />
|
|
<meshBasicMaterial
|
|
color={t.shade > 0.6 ? '#12aa40' : t.shade > 0.3 ? '#159050' : '#0d7545'}
|
|
transparent
|
|
opacity={t.brightness}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
{/* Upper canopy layer */}
|
|
<mesh position={[0, t.trunkH + t.canopyH * 0.7, 0]}>
|
|
<coneGeometry args={[t.canopyR * 0.55, t.canopyH * 0.45, 5]} />
|
|
<meshBasicMaterial
|
|
color={t.shade > 0.5 ? '#1cc855' : '#15b048'}
|
|
transparent
|
|
opacity={t.brightness * 0.6}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
))}
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// ENERGY PARTICLES — flow along all cables
|
|
// ═══════════════════════════════════════════════════════════════
|
|
const EnergyParticles = () => {
|
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
const dummy = useMemo(() => new THREE.Object3D(), []);
|
|
|
|
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, 3, 14)));
|
|
}
|
|
});
|
|
// Distribution
|
|
DIST_CABLES.forEach(([from, to]) => {
|
|
const a = from.clone(); a.y += 6;
|
|
const b = to.clone(); b.y += 6;
|
|
all.push(new THREE.CatmullRomCurve3(catenaryPoints(a, b, 2, 12)));
|
|
});
|
|
// 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.4;
|
|
const b = chain[i].clone(); b.y += 0.4;
|
|
all.push(new THREE.CatmullRomCurve3(catenaryPoints(a, b, 0.4, 8)));
|
|
}
|
|
});
|
|
});
|
|
return all;
|
|
}, []);
|
|
|
|
const perCurve = 5;
|
|
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.25 + sr(c * 100 + p * 7) * 0.4,
|
|
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);
|
|
const pulse = 0.4 + Math.sin(state.clock.elapsedTime * 8 + d.phase) * 0.35;
|
|
dummy.scale.setScalar(pulse);
|
|
dummy.updateMatrix();
|
|
meshRef.current!.setMatrixAt(i, dummy.matrix);
|
|
});
|
|
meshRef.current.instanceMatrix.needsUpdate = true;
|
|
});
|
|
|
|
return (
|
|
<instancedMesh ref={meshRef} args={[undefined, undefined, total]}>
|
|
<sphereGeometry args={[0.2, 8, 8]} />
|
|
<meshBasicMaterial
|
|
color="#70ffaa"
|
|
toneMapped={false}
|
|
/>
|
|
</instancedMesh>
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// AMBIENT ENERGY MOTES
|
|
// ═══════════════════════════════════════════════════════════════
|
|
const AmbientMotes = () => {
|
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
const count = 350;
|
|
const dummy = useMemo(() => new THREE.Object3D(), []);
|
|
const motes = useMemo(() =>
|
|
Array.from({ length: count }, (_, i) => ({
|
|
x: (sr(i * 7 + 1) - 0.5) * 320,
|
|
y: 4 + sr(i * 13 + 2) * 25,
|
|
z: (sr(i * 19 + 3) - 0.5) * 320,
|
|
speed: 0.08 + sr(i * 23 + 4) * 0.25,
|
|
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) * 4,
|
|
m.y + Math.sin(state.clock.elapsedTime * m.speed * 0.6 + m.phase) * 3,
|
|
m.z + Math.cos(state.clock.elapsedTime * m.speed + m.phase) * 4,
|
|
);
|
|
const s = 0.25 + Math.sin(state.clock.elapsedTime * 2.5 + m.phase) * 0.2;
|
|
dummy.scale.setScalar(s);
|
|
dummy.updateMatrix();
|
|
meshRef.current!.setMatrixAt(i, dummy.matrix);
|
|
});
|
|
meshRef.current.instanceMatrix.needsUpdate = true;
|
|
});
|
|
|
|
return (
|
|
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
|
<sphereGeometry args={[0.07, 5, 5]} />
|
|
<meshBasicMaterial
|
|
color="#40ff90"
|
|
transparent
|
|
opacity={0.3}
|
|
toneMapped={false}
|
|
/>
|
|
</instancedMesh>
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// GROUND FOG PARTICLES — atmospheric haze near terrain
|
|
// ═══════════════════════════════════════════════════════════════
|
|
const GroundFog = () => {
|
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
const count = 120;
|
|
const dummy = useMemo(() => new THREE.Object3D(), []);
|
|
const particles = useMemo(() =>
|
|
Array.from({ length: count }, (_, i) => {
|
|
const x = (sr(i * 31 + 100) - 0.5) * TERRAIN_R * 1.6;
|
|
const z = (sr(i * 37 + 200) - 0.5) * TERRAIN_R * 1.6;
|
|
return {
|
|
x,
|
|
y: terrainY(x, z) + 0.5 + sr(i * 43 + 300) * 3,
|
|
z,
|
|
scale: 8 + sr(i * 47 + 400) * 15,
|
|
speed: 0.02 + sr(i * 53 + 500) * 0.04,
|
|
phase: sr(i * 59 + 600) * Math.PI * 2,
|
|
};
|
|
}), []);
|
|
|
|
useFrame((state) => {
|
|
if (!meshRef.current) return;
|
|
particles.forEach((p, i) => {
|
|
dummy.position.set(
|
|
p.x + Math.sin(state.clock.elapsedTime * p.speed + p.phase) * 5,
|
|
p.y,
|
|
p.z + Math.cos(state.clock.elapsedTime * p.speed + p.phase) * 5,
|
|
);
|
|
dummy.scale.setScalar(p.scale);
|
|
dummy.updateMatrix();
|
|
meshRef.current!.setMatrixAt(i, dummy.matrix);
|
|
});
|
|
meshRef.current.instanceMatrix.needsUpdate = true;
|
|
});
|
|
|
|
return (
|
|
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
|
<sphereGeometry args={[1, 6, 6]} />
|
|
<meshBasicMaterial
|
|
color="#051828"
|
|
transparent
|
|
opacity={0.15}
|
|
depthWrite={false}
|
|
/>
|
|
</instancedMesh>
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// SCENE
|
|
// ═══════════════════════════════════════════════════════════════
|
|
const Scene = () => (
|
|
<>
|
|
<PerspectiveCamera makeDefault position={[0, 110, 180]} fov={48} near={0.5} far={600} />
|
|
<OrbitControls
|
|
enableZoom={false}
|
|
enablePan={false}
|
|
autoRotate
|
|
autoRotateSpeed={0.25}
|
|
maxPolarAngle={Math.PI / 2.2}
|
|
minPolarAngle={Math.PI / 6}
|
|
/>
|
|
|
|
{/* Lighting */}
|
|
<ambientLight intensity={0.25} color="#88aacc" />
|
|
<directionalLight position={[80, 120, 60]} intensity={1.8} color="#aaddff" />
|
|
<directionalLight position={[-60, 80, -40]} intensity={0.6} color="#4466aa" />
|
|
<pointLight position={[-80, 50, -60]} intensity={3} color="#40ff80" distance={200} decay={2} />
|
|
<pointLight position={[80, 40, 70]} intensity={2} color="#2060ff" distance={180} decay={2} />
|
|
<pointLight position={[0, 60, 0]} intensity={1.5} color="#0044aa" distance={250} decay={2} />
|
|
|
|
{/* Starfield */}
|
|
<Stars
|
|
radius={380}
|
|
depth={120}
|
|
count={22000}
|
|
factor={5}
|
|
saturation={0.1}
|
|
fade
|
|
speed={0.2}
|
|
/>
|
|
|
|
{/* Atmospheric fog */}
|
|
<fog attach="fog" args={['#020e1e', 60, 280]} />
|
|
|
|
<Terrain />
|
|
<GroundFog />
|
|
|
|
{/* Solar parks */}
|
|
{SOLAR_PARKS.map((sp, i) => (
|
|
<SolarField key={`sp-${i}`} position={sp.pos} rows={sp.rows} cols={sp.cols} />
|
|
))}
|
|
|
|
{/* Wind farms */}
|
|
{WIND_FARMS.flatMap((farm, fi) =>
|
|
farm.turbines.map((pos, ti) => (
|
|
<WindTurbine key={`wt-${fi}-${ti}`} position={pos} seed={fi * 100 + ti} />
|
|
))
|
|
)}
|
|
|
|
{/* Substations */}
|
|
{WIND_FARMS.map((f, i) => <Substation key={`ws-${i}`} position={f.sub} size="small" />)}
|
|
{SOLAR_SUBS.map((s, i) => <Substation key={`ss-${i}`} position={s} size="small" />)}
|
|
{HUBS.map((h, i) => <Substation key={`hub-${i}`} position={h} size="large" />)}
|
|
{CITY_DATA.map((cd, i) => <Substation key={`cs-${i}`} position={cd.sub} size="small" />)}
|
|
|
|
{/* Cities */}
|
|
{CITY_DATA.map((cd, i) => <City key={`city-${i}`} position={cd.city} seed={i + 1} />)}
|
|
|
|
{/* Cable network with towers */}
|
|
<CableNetwork />
|
|
|
|
{/* Forests */}
|
|
{FOREST_POSITIONS.map((pos, i) => (
|
|
<Forest
|
|
key={`f-${i}`}
|
|
position={pos}
|
|
seed={i + 1}
|
|
count={25 + Math.floor(sr(i * 61 + 700) * 30)}
|
|
/>
|
|
))}
|
|
|
|
{/* Energy flow */}
|
|
<EnergyParticles />
|
|
<AmbientMotes />
|
|
|
|
{/* Post-processing */}
|
|
<EffectComposer>
|
|
<Bloom
|
|
luminanceThreshold={0.15}
|
|
mipmapBlur
|
|
intensity={4}
|
|
luminanceSmoothing={0.7}
|
|
/>
|
|
</EffectComposer>
|
|
</>
|
|
);
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
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 (
|
|
<div className="absolute inset-0 z-0 w-full h-full cursor-grab active:cursor-grabbing"
|
|
style={{
|
|
background: 'linear-gradient(180deg, #010810 0%, #041830 35%, #0a2a55 65%, #0d3568 100%)',
|
|
}}
|
|
>
|
|
<Canvas
|
|
gl={{
|
|
antialias: true,
|
|
alpha: false,
|
|
powerPreference: 'high-performance',
|
|
toneMapping: THREE.ACESFilmicToneMapping,
|
|
toneMappingExposure: 1.2,
|
|
}}
|
|
>
|
|
<Scene />
|
|
</Canvas>
|
|
{/* Cinematic vignette overlay */}
|
|
<div
|
|
className="absolute inset-0 pointer-events-none"
|
|
style={{
|
|
background: 'radial-gradient(ellipse at center, transparent 25%, rgba(0,20,50,0.4) 70%, rgba(0,10,30,0.7) 100%)',
|
|
}}
|
|
/>
|
|
{/* Bottom fade into page */}
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-32 pointer-events-none"
|
|
style={{
|
|
background: 'linear-gradient(to top, #002b49, transparent)',
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|