Add an interactive 3D weather-journey landing page for Abrban at the site root.

Replace the root redirect with a scroll-driven three.js scene that flies from
overcast clouds through rain and a thunderstorm into a clear blue sunny sky,
fronted by Persian/RTL marketing sections. The dashboard at /dashboard is
untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-09 00:10:33 +03:30
parent dd1e70d80f
commit dfacfdc6cf
22 changed files with 2182 additions and 15 deletions
+864 -12
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -9,21 +9,28 @@
"lint": "next lint"
},
"dependencies": {
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@react-three/postprocessing": "^2.19.1",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.0",
"clsx": "^2.1.0",
"framer-motion": "^11.18.2",
"lenis": "^1.3.23",
"lucide-react": "^1.7.0",
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.0",
"react-toastify": "^11.0.5",
"three": "^0.169.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/three": "^0.169.0",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-next": "14.1.0",
+66
View File
@@ -154,3 +154,69 @@
.animate-modal-enter {
animation: modalEnter 0.2s ease-out;
}
/* ─── Lenis smooth scroll (only active on the landing page) ─── */
html.lenis,
html.lenis body {
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto !important;
}
.lenis.lenis-smooth [data-lenis-prevent] {
overscroll-behavior: contain;
}
.lenis.lenis-stopped {
overflow: hidden;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
/* ─── Landing-only helpers ───────────────────────────── */
@keyframes abrbanFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes abrbanScrollHint {
0% { opacity: 0; transform: translateY(-6px); }
50% { opacity: 1; }
100% { opacity: 0; transform: translateY(8px); }
}
@keyframes abrbanShimmer {
to { background-position: 200% center; }
}
.abrban-float { animation: abrbanFloat 6s ease-in-out infinite; }
.abrban-scroll-hint { animation: abrbanScrollHint 1.8s ease-in-out infinite; }
.abrban-shimmer {
background: linear-gradient(100deg, #93c5fd 0%, #ffffff 25%, #2563eb 50%, #ffffff 75%, #93c5fd 100%);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: abrbanShimmer 6s linear infinite;
filter: drop-shadow(0 2px 12px rgba(2, 6, 23, 0.55));
}
/* Strong legibility over the ever-changing sky (bright clouds <-> dark storm). */
.abrban-ink {
text-shadow: 0 1px 2px rgba(2, 6, 23, 0.5), 0 6px 30px rgba(2, 6, 23, 0.45);
}
/* Frosted dark-glass panel so text stays crisp on any weather stage. */
.abrban-panel {
background: rgba(15, 23, 42, 0.46);
backdrop-filter: blur(18px) saturate(125%);
-webkit-backdrop-filter: blur(18px) saturate(125%);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 30px 80px -30px rgba(2, 6, 23, 0.78),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
@media (prefers-reduced-motion: reduce) {
.abrban-float,
.abrban-scroll-hint,
.abrban-shimmer {
animation: none;
}
}
+20 -2
View File
@@ -1,5 +1,23 @@
import { redirect } from 'next/navigation';
import type { Metadata } from 'next';
import { Vazirmatn } from 'next/font/google';
import { LandingPage } from '@/components/landing/LandingPage';
const vazir = Vazirmatn({
subsets: ['arabic'],
variable: '--font-vazir',
display: 'swap',
});
export const metadata: Metadata = {
title: 'ابربان | زیرساخت ابری، در کنترل تو',
description:
'ابربان؛ پلتفرم ابریِ خودسرویس روی کوبرنتیز. اپ خود را در چند ثانیه منتشر کن — دیتابیس مدیریت‌شده، دامنهٔ اختصاصی، SSL خودکار و لاگ زنده.',
};
export default function Home() {
redirect('/dashboard');
return (
<div dir="rtl" className={`${vazir.variable} font-vazir`}>
<LandingPage />
</div>
);
}
@@ -0,0 +1,106 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import Lenis from 'lenis';
import { setScroll, setPointer, flashState } from './scroll-store';
import { SiteHeader } from './sections/SiteHeader';
import { Hero } from './sections/Hero';
import { Value } from './sections/Value';
import { Features } from './sections/Features';
import { HowItWorks } from './sections/HowItWorks';
import { Trust } from './sections/Trust';
import { FinalCta } from './sections/FinalCta';
import { Footer } from './sections/Footer';
const SceneCanvas = dynamic(() => import('./SceneCanvas'), { ssr: false });
export function LandingPage() {
const [mounted, setMounted] = useState(false);
const [reduced, setReduced] = useState(false);
const flashRef = useRef<HTMLDivElement>(null);
// Drive the full-screen lightning flash overlay from the scene's storm signal.
useEffect(() => {
let raf = 0;
const loop = () => {
if (flashRef.current) flashRef.current.style.opacity = String(flashState.value * 0.6);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
// Client mount + reduced-motion preference.
useEffect(() => {
setMounted(true);
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
// Pointer signal for camera parallax (whole-window, overlay-proof).
useEffect(() => {
const onMove = (e: PointerEvent) => {
setPointer((e.clientX / window.innerWidth) * 2 - 1, -((e.clientY / window.innerHeight) * 2 - 1));
};
window.addEventListener('pointermove', onMove, { passive: true });
return () => window.removeEventListener('pointermove', onMove);
}, []);
// Smooth scroll (Lenis) — skipped for reduced-motion, which falls back to native.
useEffect(() => {
if (reduced) {
const onScroll = () => {
const limit = document.documentElement.scrollHeight - window.innerHeight;
setScroll(limit > 0 ? window.scrollY / limit : 0);
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}
const lenis = new Lenis({ duration: 1.15, smoothWheel: true });
let raf = 0;
const loop = (time: number) => {
lenis.raf(time);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
lenis.on('scroll', () => {
setScroll(Number.isFinite(lenis.progress) ? lenis.progress : 0);
});
return () => {
cancelAnimationFrame(raf);
lenis.destroy();
};
}, [reduced]);
return (
<div className="relative min-h-screen w-full overflow-x-hidden bg-gradient-to-b from-[#9fb4d4] via-[#c3d0e0] to-[#e9f0f8] text-white">
{/* Fixed cinematic sky backdrop */}
<div className="pointer-events-none fixed inset-0 z-0">
{mounted && <SceneCanvas reduced={reduced} />}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_transparent_62%,_rgba(15,23,42,0.34)_100%)]" />
</div>
{/* Lightning flash overlay (storm stage) */}
<div ref={flashRef} className="pointer-events-none fixed inset-0 z-[5] bg-white opacity-0" />
{/* Scrolling content */}
<div className="relative z-10">
<SiteHeader />
<Hero />
<Value />
<Features />
<HowItWorks />
<Trust />
<FinalCta />
<Footer />
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Cloud } from 'lucide-react';
export function Logo({ className = '' }: { className?: string }) {
return (
<span className={`inline-flex items-center gap-2 text-xl font-black ${className}`}>
<span className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 shadow-lg shadow-primary-600/30">
<Cloud className="h-5 w-5 text-white" />
</span>
<span className="text-white">ابربان</span>
</span>
);
}
@@ -0,0 +1,26 @@
'use client';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
export function Reveal({
children,
delay = 0,
className = '',
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
@@ -0,0 +1,399 @@
'use client';
import { useMemo, useRef, useState, useEffect } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { Clouds, Cloud } from '@react-three/drei';
import { EffectComposer, Bloom, Vignette, GodRays } from '@react-three/postprocessing';
import { BlendFunction, KernelSize } from 'postprocessing';
import * as THREE from 'three';
import { scrollState, pointerState, flashState, clamp01 } from './scroll-store';
import { makeCloudField, makeRain, makeBolt, makeWeatherSample, sampleWeather } from './weather';
import { rainVertexShader, rainFragmentShader, skyVertexShader, skyFragmentShader } from './rain-shader';
// One shared, allocation-free weather sample, refreshed once per frame by
// <WeatherClock/> (mounted first) and read by every other piece of the scene.
const weather = makeWeatherSample();
const SUN_POS = new THREE.Vector3(0, 34, -205);
const SKY_RADIUS = 400;
function WeatherClock() {
useFrame(() => {
sampleWeather(clamp01(scrollState.progress), weather);
});
return null;
}
function GradientSky() {
const matRef = useRef<THREE.ShaderMaterial>(null);
const meshRef = useRef<THREE.Mesh>(null);
const uniforms = useMemo(
() => ({
uTop: { value: new THREE.Color('#9fb4d4') },
uBottom: { value: new THREE.Color('#e9f0f8') },
uRadius: { value: SKY_RADIUS },
uCameraPos: { value: new THREE.Vector3() },
uSunDir: { value: SUN_POS.clone().normalize() },
uSunColor: { value: new THREE.Color('#fff2d2') },
uSunReveal: { value: 0 },
}),
[],
);
useFrame((state) => {
const m = matRef.current;
if (!m) return;
m.uniforms.uTop.value.copy(weather.skyTop);
m.uniforms.uBottom.value.copy(weather.skyBottom);
m.uniforms.uSunColor.value.copy(weather.sunColor);
m.uniforms.uSunReveal.value = weather.sunReveal;
m.uniforms.uCameraPos.value.copy(state.camera.position);
if (meshRef.current) {
meshRef.current.position.x = state.camera.position.x;
meshRef.current.position.z = state.camera.position.z;
}
});
return (
<mesh ref={meshRef} frustumCulled={false}>
<sphereGeometry args={[SKY_RADIUS, 32, 16]} />
<shaderMaterial
ref={matRef}
side={THREE.BackSide}
depthWrite={false}
fog={false}
uniforms={uniforms}
vertexShader={skyVertexShader}
fragmentShader={skyFragmentShader}
/>
</mesh>
);
}
function SceneLights() {
const hemi = useRef<THREE.HemisphereLight>(null);
const sun = useRef<THREE.DirectionalLight>(null);
const flash = useRef<THREE.PointLight>(null);
useFrame((state) => {
if (hemi.current) {
hemi.current.color.copy(weather.hemiSky);
hemi.current.groundColor.copy(weather.hemiGround);
hemi.current.intensity = weather.hemiIntensity + flashState.value * 1.2 * weather.storm;
}
if (sun.current) {
sun.current.color.copy(weather.sunColor);
sun.current.intensity = weather.sunIntensity;
}
if (flash.current) {
flash.current.intensity = flashState.value * 9 * weather.storm;
flash.current.position.set(
state.camera.position.x + 6,
state.camera.position.y + 22,
state.camera.position.z - 18,
);
}
});
return (
<>
<hemisphereLight ref={hemi} intensity={1.1} />
<directionalLight ref={sun} position={SUN_POS} intensity={0.5} />
<pointLight ref={flash} color="#dbe6ff" intensity={0} distance={140} decay={1.4} />
</>
);
}
function CloudLayer({ clouds, segments }: { clouds: number; segments: number }) {
const group = useRef<THREE.Group>(null);
const specs = useMemo(() => makeCloudField(clouds), [clouds]);
useFrame(() => {
const g = group.current;
if (!g) return;
const o = clamp01(weather.cloudOpacity);
g.traverse((child) => {
const mat = (child as THREE.Mesh).material as THREE.Material | undefined;
if (mat && 'opacity' in mat) (mat as THREE.Material & { opacity: number }).opacity = o;
});
});
return (
<group ref={group}>
<Clouds material={THREE.MeshLambertMaterial} limit={clouds * segments + 64} frustumCulled={false}>
{specs.map((c, i) => (
<Cloud
key={i}
seed={c.seed}
segments={segments}
position={c.position}
bounds={[8, 3, 8]}
volume={c.volume}
scale={c.scale}
opacity={c.opacity}
speed={c.speed}
growth={4}
color="#ffffff"
fade={34}
/>
))}
</Clouds>
</group>
);
}
function Rain({ drops }: { drops: number }) {
const ref = useRef<THREE.LineSegments>(null);
const geometry = useMemo(() => {
const { positions, seeds } = makeRain(drops);
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(positions, 3));
g.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1));
return g;
}, [drops]);
const material = useMemo(
() =>
new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uFall: { value: 34 },
uBoxY: { value: 60 },
uWind: { value: 2.2 },
uColor: { value: new THREE.Color('#cfe2ff') },
uOpacity: { value: 0 },
},
vertexShader: rainVertexShader,
fragmentShader: rainFragmentShader,
transparent: true,
depthWrite: false,
}),
[],
);
useEffect(() => () => {
geometry.dispose();
material.dispose();
}, [geometry, material]);
useFrame((state, delta) => {
const d = Math.min(delta, 0.05);
material.uniforms.uTime.value += d;
material.uniforms.uOpacity.value = weather.rain * 0.85;
if (ref.current) {
ref.current.position.copy(state.camera.position);
ref.current.visible = weather.rain > 0.02;
}
});
return <lineSegments ref={ref} geometry={geometry} material={material} frustumCulled={false} rotation={[0, 0, 0.07]} />;
}
const BOLT_MAX = 600;
function Storm() {
const boltRef = useRef<THREE.LineSegments>(null);
const matRef = useRef<THREE.LineBasicMaterial>(null);
const nextStrike = useRef(1.5);
const flash = useRef(0);
const boltLife = useRef(0);
const strikeSeed = useRef(1);
const geometry = useMemo(() => {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(BOLT_MAX * 3), 3));
g.setDrawRange(0, 0);
return g;
}, []);
useEffect(() => () => geometry.dispose(), [geometry]);
useFrame((_, delta) => {
const d = Math.min(delta, 0.05);
const storm = weather.storm;
flash.current *= Math.exp(-d * 6.5);
nextStrike.current -= d;
if (storm > 0.22 && nextStrike.current <= 0) {
flash.current = 1;
boltLife.current = 0.22 + Math.random() * 0.14;
strikeSeed.current = (Math.random() * 1e6) | 0;
const bolt = makeBolt(strikeSeed.current);
const attr = geometry.getAttribute('position') as THREE.BufferAttribute;
const n = Math.min(bolt.length, BOLT_MAX * 3);
(attr.array as Float32Array).set(bolt.subarray(0, n));
attr.needsUpdate = true;
geometry.setDrawRange(0, n / 3);
nextStrike.current = (1.3 + Math.random() * 3.2) / Math.max(0.3, storm);
}
flashState.value = Math.min(1, flash.current);
boltLife.current -= d;
if (matRef.current) {
const v = Math.max(0, boltLife.current);
matRef.current.opacity = Math.min(1, v * 6) * (0.65 + 0.35 * Math.random());
}
if (boltRef.current) boltRef.current.visible = boltLife.current > 0 && storm > 0.2;
});
return (
<lineSegments ref={boltRef} geometry={geometry} frustumCulled={false} visible={false}>
<lineBasicMaterial
ref={matRef}
color="#e4eeff"
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</lineSegments>
);
}
function Sun({ onReady }: { onReady: (m: THREE.Mesh | null) => void }) {
const ref = useRef<THREE.Mesh | null>(null);
const matRef = useRef<THREE.MeshBasicMaterial>(null);
const coronaRef = useRef<THREE.Mesh>(null);
const coronaMat = useRef<THREE.MeshBasicMaterial>(null);
useEffect(() => {
onReady(ref.current);
return () => onReady(null);
}, [onReady]);
useFrame(() => {
const reveal = weather.sunReveal;
if (ref.current) {
ref.current.visible = reveal > 0.02;
const s = 0.55 + reveal * 0.45;
ref.current.scale.setScalar(s);
}
if (matRef.current) {
matRef.current.color.copy(weather.sunColor);
matRef.current.opacity = clamp01(reveal * 1.3);
}
if (coronaRef.current) {
coronaRef.current.visible = reveal > 0.02;
coronaRef.current.scale.setScalar(0.5 + reveal * 0.5);
}
if (coronaMat.current) {
coronaMat.current.color.copy(weather.sunColor);
coronaMat.current.opacity = reveal * 0.18;
}
});
return (
<group position={SUN_POS}>
<mesh ref={coronaRef} visible={false}>
<sphereGeometry args={[14, 24, 24]} />
<meshBasicMaterial
ref={coronaMat}
color="#fff3d2"
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh ref={ref} visible={false}>
<sphereGeometry args={[10, 32, 32]} />
<meshBasicMaterial ref={matRef} color="#fff2d2" transparent toneMapped={false} />
</mesh>
</group>
);
}
function CameraRig({ reduced }: { reduced: boolean }) {
const look = useMemo(() => new THREE.Vector3(), []);
useFrame((state, delta) => {
const p = clamp01(scrollState.progress);
const cam = state.camera;
const k = Math.min(1, delta * 2.2);
if (reduced) {
cam.position.set(0, 1.5, 12);
look.set(0, 1.5, -30);
cam.lookAt(look);
return;
}
const z = 24 + (-150 - 24) * p;
const y = 2 - Math.sin(p * Math.PI) * 3.2 + p * 2.0;
cam.position.x += (pointerState.x * 1.3 - cam.position.x) * k;
cam.position.y += (y + pointerState.y * 0.7 - cam.position.y) * k;
cam.position.z += (z - cam.position.z) * k;
look.set(
pointerState.x * 2.5,
cam.position.y + 1 + weather.sunReveal * 9,
cam.position.z - 30,
);
cam.lookAt(look);
});
return null;
}
function FogController() {
const ref = useRef<THREE.FogExp2>(null);
useFrame(() => {
if (!ref.current) return;
ref.current.color.copy(weather.fog);
ref.current.density = weather.fogDensity;
});
return <fogExp2 ref={ref} attach="fog" args={['#d8e0ec', 0.014]} />;
}
export default function SceneCanvas({ reduced }: { reduced: boolean }) {
const [sun, setSun] = useState<THREE.Mesh | null>(null);
const cfg = useMemo(() => {
if (reduced) return { clouds: 8, segments: 12, rain: 0 };
if (typeof window === 'undefined') return { clouds: 16, segments: 22, rain: 6000 };
const w = window.innerWidth;
if (w < 640) return { clouds: 9, segments: 13, rain: 1800 };
if (w < 1024) return { clouds: 13, segments: 18, rain: 4000 };
return { clouds: 16, segments: 24, rain: 6500 };
}, [reduced]);
return (
<Canvas
dpr={[1, 2]}
gl={{ antialias: true, powerPreference: 'high-performance', alpha: false }}
camera={{ position: [0, 2, 24], fov: 60, near: 0.1, far: 700 }}
>
<WeatherClock />
<FogController />
<GradientSky />
<SceneLights />
<CloudLayer clouds={cfg.clouds} segments={cfg.segments} />
{!reduced && cfg.rain > 0 && <Rain drops={cfg.rain} />}
{!reduced && <Storm />}
<Sun onReady={setSun} />
<CameraRig reduced={reduced} />
{!reduced && sun && (
<EffectComposer>
<GodRays
sun={sun}
blendFunction={BlendFunction.SCREEN}
samples={60}
density={0.86}
decay={0.94}
weight={0.26}
exposure={0.3}
clampMax={0.85}
kernelSize={KernelSize.SMALL}
blur
/>
<Bloom intensity={0.55} luminanceThreshold={0.72} luminanceSmoothing={0.4} mipmapBlur radius={0.65} />
<Vignette offset={0.2} darkness={0.55} />
</EffectComposer>
)}
</Canvas>
);
}
@@ -0,0 +1,67 @@
import {
Rocket,
Database,
ShieldCheck,
Link2,
ScrollText,
History,
CreditCard,
LifeBuoy,
Zap,
Server,
Globe,
type LucideIcon,
} from 'lucide-react';
export const BRAND = 'ابربان';
export const hero = {
title: 'ابربان',
tagline: 'زیرساخت ابری، در کنترل تو',
subtitle:
'اپلیکیشنت را در چند ثانیه روی کوبرنتیز منتشر کن — بدون دردسر سرور، بدون پیچیدگیِ DevOps.',
ctaPrimary: 'رایگان شروع کن',
ctaSecondary: 'ورود',
};
interface Feature {
icon: LucideIcon;
title: string;
desc: string;
}
export const features: Feature[] = [
{ icon: Rocket, title: 'دیپلوی برق‌آسا', desc: 'Node.js، لاراول و وردپرس را با یک کلیک منتشر کن؛ بیلد و انتشار خودکار.' },
{ icon: Database, title: 'دیتابیس مدیریت‌شده', desc: 'PostgreSQL، MySQL، Redis، RabbitMQ و Elasticsearch، آماده و پایدار.' },
{ icon: ShieldCheck, title: 'SSL خودکار', desc: 'دامنهٔ اختصاصی وصل کن؛ گواهی SSL خودکار صادر و تمدید می‌شود.' },
{ icon: Link2, title: 'لینک پیش‌نمایش', desc: 'برای هر دیپلوی یک Preview URL پایدار روی TLS بگیر و سریع تست کن.' },
{ icon: ScrollText, title: 'لاگ زنده', desc: 'لاگ بیلد و اجرای اپ را همان لحظه و به‌صورت زنده دنبال کن.' },
{ icon: History, title: 'اسنپ‌شات و بکاپ', desc: 'از وضعیت اپ اسنپ‌شات بگیر و هر زمان خواستی بازگردان.' },
{ icon: CreditCard, title: 'بیلینگ شفاف', desc: 'کیف‌پول پیش‌پرداخت، فاکتور دقیق و مصرف لحظه‌ای — بدون سورپرایز.' },
{ icon: LifeBuoy, title: 'پشتیبانی فارسی', desc: 'تیم پشتیبانی و سیستم تیکت، فارسی و همیشه کنارت.' },
];
interface Step {
n: string;
title: string;
desc: string;
}
export const steps: Step[] = [
{ n: '۱', title: 'کدت را بده', desc: 'ریپازیتوری یا اپت را وصل کن.' },
{ n: '۲', title: 'ما می‌سازیم', desc: 'ابربان به‌صورت خودکار بیلد و روی کوبرنتیز دیپلوی می‌کند.' },
{ n: '۳', title: 'آنلاین شو', desc: 'دامنه و SSL آماده است؛ اپت زنده می‌شود.' },
];
interface Kpi {
icon: LucideIcon;
kpi: string;
label: string;
}
export const trust: Kpi[] = [
{ icon: Zap, kpi: '< ۶۰ ثانیه', label: 'میانگین زمان دیپلوی' },
{ icon: Server, kpi: '۹۹٫۹٪', label: 'پایداریِ زیرساخت' },
{ icon: ShieldCheck, kpi: 'TLS خودکار', label: 'امنیتِ پیش‌فرض' },
{ icon: Globe, kpi: 'بومیِ ایران', label: 'بهینه برای کاربر ایرانی' },
];
@@ -0,0 +1,70 @@
// Rain as GL line segments. Each drop falls and wraps within a tall box that is
// recentred on the camera every frame, so it always surrounds the viewer. The
// two verts of a drop share aSeed, so the segment moves as one piece.
export const rainVertexShader = /* glsl */ `
uniform float uTime;
uniform float uFall;
uniform float uBoxY;
uniform float uWind;
attribute float aSeed;
varying float vAlpha;
void main() {
float speed = uFall * (0.55 + aSeed * 0.9);
float off = mod(uTime * speed + aSeed * uBoxY, uBoxY);
float y = position.y - off;
if (y < -uBoxY * 0.5) y += uBoxY;
float x = position.x + uWind * (off * 0.12);
vec4 mv = modelViewMatrix * vec4(x, y, position.z, 1.0);
gl_Position = projectionMatrix * mv;
// fade drops out near the top/bottom edges of the box
float h = (y + uBoxY * 0.5) / uBoxY;
vAlpha = smoothstep(0.0, 0.12, h) * smoothstep(1.0, 0.78, h);
}
`;
export const rainFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform float uOpacity;
varying float vAlpha;
void main() {
float a = vAlpha * uOpacity;
if (a <= 0.001) discard;
gl_FragColor = vec4(uColor, a);
}
`;
export const skyVertexShader = /* glsl */ `
varying vec3 vLocal;
varying vec3 vWorld;
void main() {
vLocal = position;
vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
export const skyFragmentShader = /* glsl */ `
uniform vec3 uTop;
uniform vec3 uBottom;
uniform float uRadius;
uniform vec3 uCameraPos;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform float uSunReveal;
varying vec3 vLocal;
varying vec3 vWorld;
void main() {
float h = clamp(vLocal.y / uRadius * 0.5 + 0.5, 0.0, 1.0);
vec3 col = mix(uBottom, uTop, pow(h, 0.9));
// tight sun disc + a small soft halo, so the rest of the sky stays blue
vec3 dir = normalize(vWorld - uCameraPos);
float d = max(dot(dir, normalize(uSunDir)), 0.0);
float halo = pow(d, 34.0) * 0.4 + pow(d, 220.0) * 1.3;
col = mix(col, uSunColor, clamp(halo * uSunReveal, 0.0, 1.0));
gl_FragColor = vec4(col, 1.0);
}
`;
@@ -0,0 +1,29 @@
// Lightweight scroll-progress signal read inside the r3f render loop.
// Kept as a module-level mutable so useFrame can read it without React re-renders.
export const scrollState = { progress: 0 };
export function setScroll(progress: number) {
scrollState.progress = progress < 0 ? 0 : progress > 1 ? 1 : progress;
}
// Normalized pointer (-1..1), updated from a window listener so camera parallax
// works across the whole page even where DOM sections overlay the canvas.
export const pointerState = { x: 0, y: 0 };
export function setPointer(x: number, y: number) {
pointerState.x = x;
pointerState.y = y;
}
// Lightning flash (0..1). Written by the storm controller in the render loop and
// read by the DOM flash overlay so a strike lights the whole screen, not just 3D.
export const flashState = { value: 0 };
export function clamp01(v: number) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
export function smoothstep(edge0: number, edge1: number, x: number) {
const t = clamp01((x - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
}
@@ -0,0 +1,33 @@
'use client';
import { Reveal } from '../Reveal';
import { features } from '../content';
export function Features() {
return (
<section className="relative px-6 py-28">
<div className="mx-auto max-w-6xl">
<Reveal className="mb-14 flex justify-center">
<div className="abrban-panel rounded-3xl px-8 py-7 text-center">
<h2 className="abrban-ink text-4xl font-bold text-white">هرچه برای ساختن لازم داری</h2>
<p className="abrban-ink mt-4 text-lg text-white/85">یک جعبهابزارِ کامل برای انتشار و نگهداریِ اپ.</p>
</div>
</Reveal>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{features.map((f, i) => (
<Reveal key={f.title} delay={(i % 4) * 0.07}>
<div className="abrban-panel group h-full rounded-2xl p-6 transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary-500/25 text-primary-100 ring-1 ring-primary-400/40">
<f.icon className="h-6 w-6" />
</div>
<h3 className="abrban-ink mt-5 text-lg font-bold text-white">{f.title}</h3>
<p className="mt-2 text-sm leading-7 text-white/80">{f.desc}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,35 @@
'use client';
import Link from 'next/link';
import { Reveal } from '../Reveal';
export function FinalCta() {
return (
<section className="relative px-6 py-36">
<Reveal className="mx-auto max-w-3xl">
<div className="abrban-panel rounded-[2rem] px-8 py-14 text-center sm:px-14">
<h2 className="abrban-ink text-4xl font-black leading-tight text-white sm:text-5xl">
آسمان صاف است؛ وقتِ <span className="abrban-shimmer">زنده</span> کردنِ اپِ توست.
</h2>
<p className="abrban-ink mt-6 text-lg text-white/85">
همین حالا حسابت را بساز و اولین دیپلوی را تجربه کن.
</p>
<div className="mt-9 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link
href="/register"
className="rounded-xl bg-primary-600 px-8 py-4 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
>
همین حالا شروع کن
</Link>
<Link
href="/dashboard"
className="rounded-xl border border-white/25 bg-white/10 px-8 py-4 font-bold text-white transition hover:bg-white/20"
>
داشبورد
</Link>
</div>
</div>
</Reveal>
</section>
);
}
@@ -0,0 +1,24 @@
import Link from 'next/link';
import { Logo } from '../Logo';
export function Footer() {
return (
<footer className="relative border-t border-white/15 bg-slate-950/35 px-6 py-12 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-6 sm:flex-row">
<Logo className="abrban-ink" />
<nav className="abrban-ink flex items-center gap-6 text-sm text-white/80">
<Link href="/login" className="transition hover:text-white">
ورود
</Link>
<Link href="/register" className="transition hover:text-white">
ثبتنام
</Link>
<Link href="/dashboard" className="transition hover:text-white">
داشبورد
</Link>
</nav>
<p className="abrban-ink text-sm text-white/70">© ابربان همهٔ حقوق محفوظ است</p>
</div>
</footer>
);
}
@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { ArrowLeft, ChevronDown } from 'lucide-react';
import { hero } from '../content';
export function Hero() {
return (
<section className="relative flex min-h-screen flex-col items-center justify-center px-6 text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
className="abrban-panel mx-4 max-w-2xl rounded-[2rem] px-6 py-12 sm:px-12 sm:py-14"
>
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1.5 text-sm font-medium text-white/90 backdrop-blur-md">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary-400" />
پلتفرم ابریِ خودسرویس
</span>
<h1 className="mt-7 text-6xl font-black tracking-tight sm:text-7xl md:text-8xl">
<span className="abrban-shimmer">{hero.title}</span>
</h1>
<p className="abrban-ink mt-5 text-2xl font-bold text-white sm:text-3xl">{hero.tagline}</p>
<p className="abrban-ink mx-auto mt-5 max-w-2xl text-base leading-8 text-white/85 sm:text-lg">
{hero.subtitle}
</p>
<div className="mt-9 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link
href="/register"
className="group inline-flex items-center gap-2 rounded-xl bg-primary-600 px-7 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 hover:shadow-primary-500/40"
>
{hero.ctaPrimary}
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-1" />
</Link>
<Link
href="/login"
className="rounded-xl border border-white/20 bg-white/5 px-7 py-3.5 font-bold text-white backdrop-blur-md transition hover:bg-white/10"
>
{hero.ctaSecondary}
</Link>
</div>
</motion.div>
<div className="abrban-ink absolute bottom-10 flex flex-col items-center text-white/80">
<span className="text-xs font-medium">به دلِ ابرها بزن</span>
<ChevronDown className="abrban-scroll-hint mt-1 h-5 w-5" />
</div>
</section>
);
}
@@ -0,0 +1,32 @@
'use client';
import { Reveal } from '../Reveal';
import { steps } from '../content';
export function HowItWorks() {
return (
<section className="relative px-6 py-28">
<div className="mx-auto max-w-5xl">
<Reveal className="mb-16 flex justify-center">
<h2 className="abrban-panel abrban-ink rounded-3xl px-8 py-5 text-4xl font-bold text-white">
به سادگیِ سه قدم
</h2>
</Reveal>
<div className="grid gap-8 md:grid-cols-3">
{steps.map((s, i) => (
<Reveal key={s.n} delay={i * 0.1}>
<div className="abrban-panel relative rounded-2xl p-8 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary-600/35 text-2xl font-black text-primary-100 ring-1 ring-primary-400/50">
{s.n}
</div>
<h3 className="abrban-ink mt-5 text-xl font-bold text-white">{s.title}</h3>
<p className="mt-2 leading-8 text-white/80">{s.desc}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,34 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Logo } from '../Logo';
export function SiteHeader() {
return (
<motion.header
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
className="fixed inset-x-0 top-0 z-30"
>
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<Logo className="abrban-ink" />
<div className="flex items-center gap-2 rounded-xl bg-slate-950/30 p-1.5 backdrop-blur-md ring-1 ring-white/10">
<Link
href="/login"
className="rounded-lg px-4 py-2 text-sm font-semibold text-white/90 transition hover:text-white"
>
ورود
</Link>
<Link
href="/register"
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
>
ثبتنام
</Link>
</div>
</div>
</motion.header>
);
}
@@ -0,0 +1,30 @@
'use client';
import { Reveal } from '../Reveal';
import { trust } from '../content';
export function Trust() {
return (
<section className="relative px-6 py-28">
<div className="mx-auto max-w-6xl">
<Reveal className="mb-14 flex justify-center">
<h2 className="abrban-panel abrban-ink rounded-3xl px-8 py-5 text-center text-3xl font-bold text-white sm:text-4xl">
چرا تیمها به ابربان اعتماد میکنند
</h2>
</Reveal>
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
{trust.map((t, i) => (
<Reveal key={t.label} delay={(i % 4) * 0.07}>
<div className="abrban-panel rounded-2xl p-7 text-center">
<t.icon className="mx-auto h-7 w-7 text-primary-200" />
<div className="abrban-ink mt-4 text-3xl font-black text-white">{t.kpi}</div>
<div className="mt-1 text-sm text-white/75">{t.label}</div>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,21 @@
'use client';
import { Reveal } from '../Reveal';
export function Value() {
return (
<section className="relative px-6 py-32">
<Reveal className="mx-auto max-w-3xl">
<div className="abrban-panel rounded-3xl px-8 py-12 text-center sm:px-12">
<h2 className="abrban-ink text-3xl font-bold leading-snug text-white sm:text-4xl">
یک پلتفرمِ <span className="text-primary-300">PaaS خودسرویس</span> روی کوبرنتیز
تمامِ قدرتِ زیرساختِ ابری، بدون پیچیدگیِ طوفانیاش.
</h2>
<p className="abrban-ink mt-6 text-lg leading-9 text-white/85">
ابربان لایههای سختِ DevOps را برایت مدیریت میکند تا فقط روی محصولت تمرکز کنی.
</p>
</div>
</Reveal>
</section>
);
}
+249
View File
@@ -0,0 +1,249 @@
import * as THREE from 'three';
// A scroll-driven weather journey: float among bright clouds -> dive into them ->
// rain -> thunderstorm -> the sky clears to brilliant sun. Each stop below is a
// keyframe; the scene linearly interpolates between adjacent stops by scroll
// progress (0..1) every frame, so colors/fog/light/rain all move together.
interface WeatherStop {
p: number;
skyTop: string;
skyBottom: string;
fog: string;
fogDensity: number;
hemiSky: string;
hemiGround: string;
hemiIntensity: number;
sunColor: string;
sunIntensity: number;
cloudOpacity: number;
rain: number; // 0..1 rain intensity
storm: number; // 0..1 lightning likelihood
sunReveal: number; // 0..1 sun disc brightness + glow
}
const STOPS: WeatherStop[] = [
// 0 — calm, bright overcast: floating among soft white clouds (Hero)
{ p: 0.0, skyTop: '#9fb4d4', skyBottom: '#e9f0f8', fog: '#d8e0ec', fogDensity: 0.014, hemiSky: '#dbe8fb', hemiGround: '#c6cfdb', hemiIntensity: 1.2, sunColor: '#eef4ff', sunIntensity: 0.45, cloudOpacity: 0.85, rain: 0, storm: 0, sunReveal: 0 },
// 1 — diving into the cloud deck: fog thickens, light grays out
{ p: 0.2, skyTop: '#8090a6', skyBottom: '#bac6d4', fog: '#aab6c5', fogDensity: 0.04, hemiSky: '#c0cddd', hemiGround: '#a6b0bd', hemiIntensity: 0.85, sunColor: '#dde6f2', sunIntensity: 0.25, cloudOpacity: 1.0, rain: 0.12, storm: 0, sunReveal: 0 },
// 2 — rain: dark gray cloud, streaks falling
{ p: 0.42, skyTop: '#525e70', skyBottom: '#7c8a9a', fog: '#6d7b8c', fogDensity: 0.052, hemiSky: '#838fa0', hemiGround: '#67707e', hemiIntensity: 0.6, sunColor: '#b7c2d0', sunIntensity: 0.16, cloudOpacity: 1.0, rain: 0.75, storm: 0.18, sunReveal: 0 },
// 3 — thunderstorm peak: darkest, heavy rain + lightning
{ p: 0.64, skyTop: '#1f2735', skyBottom: '#374150', fog: '#2b3543', fogDensity: 0.06, hemiSky: '#3a4453', hemiGround: '#242b37', hemiIntensity: 0.38, sunColor: '#7c8898', sunIntensity: 0.08, cloudOpacity: 1.0, rain: 1.0, storm: 1.0, sunReveal: 0 },
// 4 — clouds part, sun breaks through, sky brightens to blue
{ p: 0.85, skyTop: '#2f7fd2', skyBottom: '#d2eaff', fog: '#c4e3ff', fogDensity: 0.015, hemiSky: '#bfe3ff', hemiGround: '#ffe8be', hemiIntensity: 1.3, sunColor: '#fff2d2', sunIntensity: 1.5, cloudOpacity: 0.34, rain: 0.08, storm: 0, sunReveal: 0.72 },
// 5 — clear, calm blue sky, no clouds, bright open sun
{ p: 1.0, skyTop: '#1763c8', skyBottom: '#8ccbff', fog: '#bfe4ff', fogDensity: 0.003, hemiSky: '#bfe4ff', hemiGround: '#fff1d2', hemiIntensity: 1.45, sunColor: '#fff2cc', sunIntensity: 2.0, cloudOpacity: 0.0, rain: 0, storm: 0, sunReveal: 1.0 },
];
interface StopColors {
p: number;
skyTop: THREE.Color;
skyBottom: THREE.Color;
fog: THREE.Color;
fogDensity: number;
hemiSky: THREE.Color;
hemiGround: THREE.Color;
hemiIntensity: number;
sunColor: THREE.Color;
sunIntensity: number;
cloudOpacity: number;
rain: number;
storm: number;
sunReveal: number;
}
const STOP_COLORS: StopColors[] = STOPS.map((s) => ({
p: s.p,
skyTop: new THREE.Color(s.skyTop),
skyBottom: new THREE.Color(s.skyBottom),
fog: new THREE.Color(s.fog),
fogDensity: s.fogDensity,
hemiSky: new THREE.Color(s.hemiSky),
hemiGround: new THREE.Color(s.hemiGround),
hemiIntensity: s.hemiIntensity,
sunColor: new THREE.Color(s.sunColor),
sunIntensity: s.sunIntensity,
cloudOpacity: s.cloudOpacity,
rain: s.rain,
storm: s.storm,
sunReveal: s.sunReveal,
}));
export interface WeatherSample {
skyTop: THREE.Color;
skyBottom: THREE.Color;
fog: THREE.Color;
fogDensity: number;
hemiSky: THREE.Color;
hemiGround: THREE.Color;
hemiIntensity: number;
sunColor: THREE.Color;
sunIntensity: number;
cloudOpacity: number;
rain: number;
storm: number;
sunReveal: number;
}
export function makeWeatherSample(): WeatherSample {
return {
skyTop: new THREE.Color(),
skyBottom: new THREE.Color(),
fog: new THREE.Color(),
fogDensity: 0,
hemiSky: new THREE.Color(),
hemiGround: new THREE.Color(),
hemiIntensity: 0,
sunColor: new THREE.Color(),
sunIntensity: 0,
cloudOpacity: 0,
rain: 0,
storm: 0,
sunReveal: 0,
};
}
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
// Interpolate the palette at progress `p` into `out` (mutated, no allocation).
export function sampleWeather(p: number, out: WeatherSample): WeatherSample {
const prog = p < 0 ? 0 : p > 1 ? 1 : p;
let i = 0;
while (i < STOP_COLORS.length - 2 && prog > STOP_COLORS[i + 1].p) i++;
const a = STOP_COLORS[i];
const b = STOP_COLORS[i + 1];
const span = b.p - a.p || 1;
let t = (prog - a.p) / span;
t = t < 0 ? 0 : t > 1 ? 1 : t;
t = t * t * (3 - 2 * t); // smoothstep for buttery transitions
out.skyTop.copy(a.skyTop).lerp(b.skyTop, t);
out.skyBottom.copy(a.skyBottom).lerp(b.skyBottom, t);
out.fog.copy(a.fog).lerp(b.fog, t);
out.hemiSky.copy(a.hemiSky).lerp(b.hemiSky, t);
out.hemiGround.copy(a.hemiGround).lerp(b.hemiGround, t);
out.sunColor.copy(a.sunColor).lerp(b.sunColor, t);
out.fogDensity = lerp(a.fogDensity, b.fogDensity, t);
out.hemiIntensity = lerp(a.hemiIntensity, b.hemiIntensity, t);
out.sunIntensity = lerp(a.sunIntensity, b.sunIntensity, t);
out.cloudOpacity = lerp(a.cloudOpacity, b.cloudOpacity, t);
out.rain = lerp(a.rain, b.rain, t);
out.storm = lerp(a.storm, b.storm, t);
out.sunReveal = lerp(a.sunReveal, b.sunReveal, t);
return out;
}
// ── Cloud field layout ────────────────────────────────────────────────
// Billboardy volumetric clouds spread along -Z so the camera flies through them.
// Denser in the middle (the storm), thinning out near the end so the sky opens
// up to the sun. Deterministic (seeded) for stable frames.
export interface CloudSpec {
position: [number, number, number];
scale: number;
seed: number;
volume: number;
opacity: number;
speed: number;
}
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function makeCloudField(count: number): CloudSpec[] {
const rnd = mulberry32(0x5eed1234);
const clouds: CloudSpec[] = [];
// Z range the camera travels through (start ~ +18, ends near the sun ~ -150).
const zStart = 14;
const zEnd = -150;
for (let i = 0; i < count; i++) {
const u = i / Math.max(1, count - 1);
// Bias clouds toward the middle of the journey (the storm region).
const z = zStart + (zEnd - zStart) * Math.min(1, u * 0.92 + (rnd() - 0.5) * 0.06);
const stormy = 1 - Math.abs(u - 0.62) / 0.62; // 0..1, peaks mid-journey
const spread = 16 + 14 * (1 - stormy);
const x = (rnd() - 0.5) * spread * 2;
const y = (rnd() - 0.5) * 14 - 1.5;
const scale = 1.4 + rnd() * 2.6 + stormy * 1.2;
clouds.push({
position: [x, y, z],
scale,
seed: Math.floor(rnd() * 1e6),
volume: 7 + rnd() * 9 + stormy * 4,
opacity: 0.55 + rnd() * 0.4,
speed: 0.12 + rnd() * 0.25,
});
}
return clouds;
}
// ── Rain geometry ─────────────────────────────────────────────────────
// Each drop is a short vertical line segment (2 verts). aSeed drives a per-drop
// fall phase/speed in the shader; the field is a box recentred on the camera.
export interface RainGeometryData {
positions: Float32Array; // xyz per vertex
seeds: Float32Array; // one value per vertex (shared per drop)
}
export function makeRain(count: number, box = { x: 64, y: 60, z: 60 }): RainGeometryData {
const rnd = mulberry32(0xa11ce);
const positions = new Float32Array(count * 2 * 3);
const seeds = new Float32Array(count * 2);
for (let i = 0; i < count; i++) {
const x = (rnd() - 0.5) * box.x;
const y = (rnd() - 0.5) * box.y;
const z = (rnd() - 0.5) * box.z;
const len = 1.1 + rnd() * 1.6;
const seed = rnd();
const o = i * 6;
// top vertex
positions[o] = x;
positions[o + 1] = y + len;
positions[o + 2] = z;
// bottom vertex
positions[o + 3] = x;
positions[o + 4] = y;
positions[o + 5] = z;
seeds[i * 2] = seed;
seeds[i * 2 + 1] = seed;
}
return { positions, seeds };
}
// A jagged top-to-bottom lightning bolt path (x,y,z triples) for LineSegments.
export function makeBolt(seedBase: number): Float32Array {
const rnd = mulberry32(seedBase >>> 0);
const segments = 11 + Math.floor(rnd() * 6);
const top = 26 + rnd() * 8;
const bottom = -16 - rnd() * 6;
const x0 = (rnd() - 0.5) * 36;
const z = -40 - rnd() * 50;
const pts: number[] = [];
let x = x0;
let prevY = top;
let prevX = x;
for (let s = 1; s <= segments; s++) {
const y = top + (bottom - top) * (s / segments);
x += (rnd() - 0.5) * 6;
// line segment from previous point to this point
pts.push(prevX, prevY, z, x, y, z);
// occasional fork
if (rnd() > 0.78 && s < segments - 1) {
pts.push(x, y, z, x + (rnd() - 0.5) * 9, y - 3 - rnd() * 4, z);
}
prevX = x;
prevY = y;
}
return new Float32Array(pts);
}
+3
View File
@@ -8,6 +8,9 @@ const config: Config = {
],
theme: {
extend: {
fontFamily: {
vazir: ['var(--font-vazir)', 'system-ui', 'sans-serif'],
},
colors: {
primary: {
50: '#eff6ff',
File diff suppressed because one or more lines are too long