590 lines
19 KiB
Rust
590 lines
19 KiB
Rust
use wasm_bindgen::prelude::*;
|
|
use wasm_bindgen::JsCast;
|
|
use web_sys::{
|
|
console, Document, Element, HtmlElement, Window,
|
|
IntersectionObserver, IntersectionObserverEntry, IntersectionObserverInit,
|
|
HtmlCanvasElement, CanvasRenderingContext2d,
|
|
};
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
// Particle structure
|
|
struct Particle {
|
|
x: f64,
|
|
y: f64,
|
|
vx: f64,
|
|
vy: f64,
|
|
size: f64,
|
|
life: f64,
|
|
max_life: f64,
|
|
hue: f64,
|
|
}
|
|
|
|
impl Particle {
|
|
fn new(x: f64, y: f64, hue: f64) -> Self {
|
|
Self {
|
|
x,
|
|
y,
|
|
vx: 0.0,
|
|
vy: 0.0,
|
|
size: js_sys::Math::random() * 2.0 + 2.0,
|
|
life: 1.0,
|
|
max_life: js_sys::Math::random() * 500.0 + 500.0,
|
|
hue,
|
|
}
|
|
}
|
|
|
|
fn update(&mut self, width: f64, height: f64, time: f64) {
|
|
// Multiple layered flow fields for complex fluid motion
|
|
let scale1 = 0.003;
|
|
let scale2 = 0.006;
|
|
let scale3 = 0.001;
|
|
|
|
// Layer 1: Large sweeping currents
|
|
let angle1 = (self.x * scale1 + time * 0.0002).sin() * 3.0
|
|
+ (self.y * scale1 + time * 0.0001).cos() * 3.0;
|
|
|
|
// Layer 2: Medium turbulence
|
|
let angle2 = (self.x * scale2 - time * 0.0003).cos() * 2.0
|
|
+ (self.y * scale2 + time * 0.0002).sin() * 2.0;
|
|
|
|
// Layer 3: Fine detail
|
|
let angle3 = ((self.x * scale3 + time * 0.0001).sin()
|
|
+ (self.y * scale3 - time * 0.00015).cos()) * 1.5;
|
|
|
|
// Combine flow fields
|
|
let angle = angle1 + angle2 + angle3;
|
|
|
|
// Convert to force
|
|
let force = 0.15;
|
|
let fx = angle.cos() * force;
|
|
let fy = angle.sin() * force;
|
|
|
|
// Apply force with gentle acceleration
|
|
self.vx += fx;
|
|
self.vy += fy;
|
|
|
|
// Smooth damping for fluid motion
|
|
self.vx *= 0.95;
|
|
self.vy *= 0.95;
|
|
|
|
// Limit maximum velocity for smooth flow
|
|
let max_speed = 2.0;
|
|
let speed = (self.vx * self.vx + self.vy * self.vy).sqrt();
|
|
if speed > max_speed {
|
|
self.vx = (self.vx / speed) * max_speed;
|
|
self.vy = (self.vy / speed) * max_speed;
|
|
}
|
|
|
|
// Update position
|
|
self.x += self.vx;
|
|
self.y += self.vy;
|
|
|
|
self.life -= 1.0;
|
|
|
|
// Wrap around edges smoothly
|
|
if self.x < -10.0 {
|
|
self.x = width + 10.0;
|
|
}
|
|
if self.x > width + 10.0 {
|
|
self.x = -10.0;
|
|
}
|
|
if self.y < -10.0 {
|
|
self.y = height + 10.0;
|
|
}
|
|
if self.y > height + 10.0 {
|
|
self.y = -10.0;
|
|
}
|
|
}
|
|
|
|
fn is_dead(&self) -> bool {
|
|
self.life <= 0.0
|
|
}
|
|
|
|
fn draw(&self, ctx: &CanvasRenderingContext2d) {
|
|
let alpha = (self.life / self.max_life).min(1.0);
|
|
|
|
// Glow effect
|
|
let glow_gradient = ctx.create_radial_gradient(
|
|
self.x, self.y, 0.0,
|
|
self.x, self.y, self.size * 3.0
|
|
).ok();
|
|
|
|
if let Some(gradient) = glow_gradient {
|
|
let alpha_hex = format!("{:02x}", (alpha * 60.0) as u8);
|
|
gradient.add_color_stop(0.0, &format!("#{}", alpha_hex.repeat(3))).ok();
|
|
gradient.add_color_stop(0.5, &format!("rgba(50, 255, 150, {})", alpha * 0.3)).ok();
|
|
gradient.add_color_stop(1.0, "rgba(50, 255, 150, 0)").ok();
|
|
|
|
ctx.set_fill_style(&JsValue::from(gradient));
|
|
ctx.begin_path();
|
|
let _ = ctx.arc(self.x, self.y, self.size * 3.0, 0.0, std::f64::consts::PI * 2.0);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Core particle
|
|
let color = format!("rgba(100, 255, 180, {})", alpha * 0.8);
|
|
ctx.set_fill_style_str(&color);
|
|
ctx.begin_path();
|
|
let _ = ctx.arc(self.x, self.y, self.size, 0.0, std::f64::consts::PI * 2.0);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Particle system
|
|
struct ParticleSystem {
|
|
particles: Vec<Particle>,
|
|
width: f64,
|
|
height: f64,
|
|
max_particles: usize,
|
|
time: f64,
|
|
}
|
|
|
|
impl ParticleSystem {
|
|
fn new(width: f64, height: f64) -> Self {
|
|
Self {
|
|
particles: Vec::new(),
|
|
width,
|
|
height,
|
|
max_particles: 1200,
|
|
time: 0.0,
|
|
}
|
|
}
|
|
|
|
fn spawn_particles(&mut self, count: usize) {
|
|
for _ in 0..count {
|
|
if self.particles.len() < self.max_particles {
|
|
let x = js_sys::Math::random() * self.width;
|
|
let y = js_sys::Math::random() * self.height;
|
|
// Green hues: 120-160 (emerald to lime)
|
|
let hue = js_sys::Math::random() * 40.0 + 120.0;
|
|
self.particles.push(Particle::new(x, y, hue));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update(&mut self) {
|
|
self.time += 1.0;
|
|
|
|
// Update existing particles
|
|
for particle in &mut self.particles {
|
|
particle.update(self.width, self.height, self.time);
|
|
}
|
|
|
|
// Remove dead particles
|
|
self.particles.retain(|p| !p.is_dead());
|
|
|
|
// Spawn new particles
|
|
let spawn_count = (self.max_particles - self.particles.len()).min(5);
|
|
self.spawn_particles(spawn_count);
|
|
}
|
|
|
|
fn draw(&self, ctx: &CanvasRenderingContext2d) {
|
|
for particle in &self.particles {
|
|
particle.draw(ctx);
|
|
}
|
|
}
|
|
|
|
fn resize(&mut self, width: f64, height: f64) {
|
|
self.width = width;
|
|
self.height = height;
|
|
}
|
|
}
|
|
|
|
#[wasm_bindgen(start)]
|
|
pub fn main() -> Result<(), JsValue> {
|
|
console::log_1(&"🚀 railwayka.ru WASM landing page initializing...".into());
|
|
|
|
let window = web_sys::window().expect("no global window exists");
|
|
let document = window.document().expect("should have a document on window");
|
|
|
|
// Clear body and build page
|
|
if let Some(body) = document.body() {
|
|
body.set_inner_html("");
|
|
|
|
// Create and setup particle canvas
|
|
setup_particle_canvas(&document, &body, &window)?;
|
|
|
|
build_page(&document, &body)?;
|
|
setup_scroll_animations(&document)?;
|
|
}
|
|
|
|
console::log_1(&"✅ Landing page ready!".into());
|
|
Ok(())
|
|
}
|
|
|
|
fn setup_particle_canvas(document: &Document, body: &HtmlElement, window: &Window) -> Result<(), JsValue> {
|
|
console::log_1(&"🎨 Setting up particle canvas...".into());
|
|
|
|
// Create canvas element
|
|
let canvas = document
|
|
.create_element("canvas")?
|
|
.dyn_into::<HtmlCanvasElement>()?;
|
|
canvas.set_id("particle-canvas");
|
|
|
|
// Get window dimensions
|
|
let width = window.inner_width()?.as_f64().unwrap_or(800.0);
|
|
let height = window.inner_height()?.as_f64().unwrap_or(600.0);
|
|
|
|
console::log_1(&format!("📐 Canvas size: {}x{}", width, height).into());
|
|
|
|
canvas.set_width(width as u32);
|
|
canvas.set_height(height as u32);
|
|
|
|
body.append_child(&canvas)?;
|
|
console::log_1(&"✅ Canvas appended to body".into());
|
|
|
|
// Get 2D context
|
|
let context = canvas
|
|
.get_context("2d")?
|
|
.unwrap()
|
|
.dyn_into::<CanvasRenderingContext2d>()?;
|
|
|
|
// Initialize particle system
|
|
let particle_system = Rc::new(RefCell::new(ParticleSystem::new(width, height)));
|
|
|
|
// Spawn initial particles
|
|
particle_system.borrow_mut().spawn_particles(600);
|
|
console::log_1(&format!("🌟 Spawned {} initial particles", particle_system.borrow().particles.len()).into());
|
|
|
|
// Setup animation loop
|
|
let system_clone = particle_system.clone();
|
|
let context_clone = context.clone();
|
|
let frame_count = Rc::new(RefCell::new(0u32));
|
|
let window_clone = window.clone();
|
|
|
|
let animate_closure = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
|
|
let animate_clone = animate_closure.clone();
|
|
let frame_clone = frame_count.clone();
|
|
|
|
*animate_closure.borrow_mut() = Some(Closure::wrap(Box::new(move || {
|
|
*frame_clone.borrow_mut() += 1;
|
|
|
|
if *frame_clone.borrow() == 1 {
|
|
console::log_1(&"🎬 Animation loop started!".into());
|
|
}
|
|
|
|
if *frame_clone.borrow() % 60 == 0 {
|
|
let sys = system_clone.borrow();
|
|
console::log_1(&format!("🔄 Frame {}, {} particles",
|
|
*frame_clone.borrow(),
|
|
sys.particles.len()).into());
|
|
|
|
if let Some(p) = sys.particles.first() {
|
|
console::log_1(&format!("🔍 First particle: x={:.1}, y={:.1}, vx={:.2}, vy={:.2}, size={:.1}, life={:.1}",
|
|
p.x, p.y, p.vx, p.vy, p.size, p.life).into());
|
|
}
|
|
}
|
|
|
|
// Semi-transparent clear for fluid trails
|
|
context_clone.set_fill_style_str("rgba(10, 14, 23, 0.12)");
|
|
context_clone.fill_rect(0.0, 0.0, width, height);
|
|
|
|
// Update and draw particles
|
|
system_clone.borrow_mut().update();
|
|
system_clone.borrow().draw(&context_clone);
|
|
|
|
// Request next frame
|
|
if let Some(closure) = animate_clone.borrow().as_ref() {
|
|
window_clone
|
|
.request_animation_frame(closure.as_ref().unchecked_ref())
|
|
.ok();
|
|
}
|
|
}) as Box<dyn FnMut()>));
|
|
|
|
// Start animation
|
|
if let Some(closure) = animate_closure.borrow().as_ref() {
|
|
window.request_animation_frame(closure.as_ref().unchecked_ref())?;
|
|
}
|
|
|
|
// Setup resize handler
|
|
let canvas_clone = canvas.clone();
|
|
let system_resize = particle_system.clone();
|
|
let window_clone = window.clone();
|
|
|
|
let resize_closure = Closure::wrap(Box::new(move || {
|
|
let new_width = window_clone.inner_width().unwrap().as_f64().unwrap_or(800.0);
|
|
let new_height = window_clone.inner_height().unwrap().as_f64().unwrap_or(600.0);
|
|
|
|
canvas_clone.set_width(new_width as u32);
|
|
canvas_clone.set_height(new_height as u32);
|
|
|
|
system_resize.borrow_mut().resize(new_width, new_height);
|
|
}) as Box<dyn FnMut()>);
|
|
|
|
window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())?;
|
|
resize_closure.forget();
|
|
|
|
console::log_1(&"🌊 Particle system initialized!".into());
|
|
Ok(())
|
|
}
|
|
|
|
fn build_page(document: &Document, body: &HtmlElement) -> Result<(), JsValue> {
|
|
// Hero Section
|
|
let hero = create_hero(document)?;
|
|
body.append_child(&hero)?;
|
|
|
|
// Services Section
|
|
let services = create_services_section(document)?;
|
|
body.append_child(&services)?;
|
|
|
|
// Tech Stack Section
|
|
let tech = create_tech_section(document)?;
|
|
body.append_child(&tech)?;
|
|
|
|
// Footer
|
|
let footer = create_footer(document)?;
|
|
body.append_child(&footer)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn create_hero(document: &Document) -> Result<Element, JsValue> {
|
|
let section = document.create_element("section")?;
|
|
section.set_class_name("hero");
|
|
|
|
let container = document.create_element("div")?;
|
|
container.set_class_name("container");
|
|
|
|
let title = document.create_element("h1")?;
|
|
title.set_text_content(Some("railwayka.ru"));
|
|
title.set_class_name("hero-title");
|
|
|
|
let subtitle = document.create_element("p")?;
|
|
subtitle.set_text_content(Some("Modern self-hosted infrastructure powered by GitOps"));
|
|
subtitle.set_class_name("hero-subtitle");
|
|
|
|
let badge = document.create_element("div")?;
|
|
badge.set_class_name("hero-badge");
|
|
badge.set_text_content(Some("🦀 Powered by Rust + WebAssembly"));
|
|
|
|
container.append_child(&title)?;
|
|
container.append_child(&subtitle)?;
|
|
container.append_child(&badge)?;
|
|
section.append_child(&container)?;
|
|
|
|
Ok(section)
|
|
}
|
|
|
|
fn create_services_section(document: &Document) -> Result<Element, JsValue> {
|
|
let section = document.create_element("section")?;
|
|
section.set_class_name("services");
|
|
|
|
let container = document.create_element("div")?;
|
|
container.set_class_name("container");
|
|
|
|
let heading = document.create_element("h2")?;
|
|
heading.set_text_content(Some("Services"));
|
|
heading.set_class_name("section-title");
|
|
container.append_child(&heading)?;
|
|
|
|
let grid = document.create_element("div")?;
|
|
grid.set_class_name("services-grid");
|
|
|
|
// Live services
|
|
let services_live = vec![
|
|
("Git Repository", "Gitea - Self-hosted Git server with web UI", "🔴", "https://git.dc.railwayka.ru"),
|
|
];
|
|
|
|
for (i, (name, desc, status, url)) in services_live.iter().enumerate() {
|
|
let card = create_service_card(document, name, desc, status, Some(url))?;
|
|
card.set_attribute("data-animate", "fade-up")?;
|
|
card.set_attribute("data-delay", &format!("{}", i * 100))?;
|
|
grid.append_child(&card)?;
|
|
}
|
|
|
|
// Planned services
|
|
let services_planned = vec![
|
|
("Matrix Server", "Decentralized messaging and communication", "🔵"),
|
|
("Blog", "Technical writing and knowledge sharing", "🔵"),
|
|
("Password Manager", "Vaultwarden - Self-hosted password vault", "🔵"),
|
|
("Cloud Storage", "Nextcloud - File sync and sharing", "🔵"),
|
|
("Monitoring", "Grafana + Prometheus - Infrastructure monitoring", "🔵"),
|
|
];
|
|
|
|
for (i, (name, desc, status)) in services_planned.iter().enumerate() {
|
|
let card = create_service_card(document, name, desc, status, None)?;
|
|
card.set_attribute("data-animate", "fade-up")?;
|
|
card.set_attribute("data-delay", &format!("{}", (i + services_live.len()) * 100))?;
|
|
grid.append_child(&card)?;
|
|
}
|
|
|
|
container.append_child(&grid)?;
|
|
section.append_child(&container)?;
|
|
|
|
Ok(section)
|
|
}
|
|
|
|
fn create_service_card(
|
|
document: &Document,
|
|
name: &str,
|
|
description: &str,
|
|
status: &str,
|
|
url: Option<&str>,
|
|
) -> Result<Element, JsValue> {
|
|
let card = if let Some(link) = url {
|
|
let a = document.create_element("a")?;
|
|
a.set_attribute("href", link)?;
|
|
a.set_attribute("target", "_blank")?;
|
|
a.set_attribute("rel", "noopener noreferrer")?;
|
|
a
|
|
} else {
|
|
document.create_element("div")?
|
|
};
|
|
card.set_class_name("service-card");
|
|
|
|
let header = document.create_element("div")?;
|
|
header.set_class_name("service-header");
|
|
|
|
let title = document.create_element("h3")?;
|
|
title.set_text_content(Some(name));
|
|
|
|
let badge = document.create_element("span")?;
|
|
badge.set_class_name("status-badge");
|
|
badge.set_text_content(Some(status));
|
|
|
|
header.append_child(&title)?;
|
|
header.append_child(&badge)?;
|
|
|
|
let desc = document.create_element("p")?;
|
|
desc.set_text_content(Some(description));
|
|
desc.set_class_name("service-description");
|
|
|
|
card.append_child(&header)?;
|
|
card.append_child(&desc)?;
|
|
|
|
Ok(card)
|
|
}
|
|
|
|
fn create_tech_section(document: &Document) -> Result<Element, JsValue> {
|
|
let section = document.create_element("section")?;
|
|
section.set_class_name("tech");
|
|
|
|
let container = document.create_element("div")?;
|
|
container.set_class_name("container");
|
|
|
|
let heading = document.create_element("h2")?;
|
|
heading.set_text_content(Some("Technology Stack"));
|
|
heading.set_class_name("section-title");
|
|
container.append_child(&heading)?;
|
|
|
|
let grid = document.create_element("div")?;
|
|
grid.set_class_name("tech-grid");
|
|
|
|
let technologies = vec![
|
|
("Dcape", "GitOps platform for containerized applications"),
|
|
("Docker", "Containerization and orchestration"),
|
|
("Traefik", "Automatic HTTPS and reverse proxy"),
|
|
("PowerDNS", "Authoritative DNS server"),
|
|
("PostgreSQL", "Shared database infrastructure"),
|
|
("Rust + WASM", "High-performance web applications"),
|
|
];
|
|
|
|
for (i, (name, desc)) in technologies.iter().enumerate() {
|
|
let item = create_tech_item(document, name, desc)?;
|
|
item.set_attribute("data-animate", "fade-up")?;
|
|
item.set_attribute("data-delay", &format!("{}", i * 80))?;
|
|
grid.append_child(&item)?;
|
|
}
|
|
|
|
container.append_child(&grid)?;
|
|
section.append_child(&container)?;
|
|
|
|
Ok(section)
|
|
}
|
|
|
|
fn create_tech_item(document: &Document, name: &str, description: &str) -> Result<Element, JsValue> {
|
|
let item = document.create_element("div")?;
|
|
item.set_class_name("tech-item");
|
|
|
|
let title = document.create_element("h3")?;
|
|
title.set_text_content(Some(name));
|
|
|
|
let desc = document.create_element("p")?;
|
|
desc.set_text_content(Some(description));
|
|
|
|
item.append_child(&title)?;
|
|
item.append_child(&desc)?;
|
|
|
|
Ok(item)
|
|
}
|
|
|
|
fn create_footer(document: &Document) -> Result<Element, JsValue> {
|
|
let footer = document.create_element("footer")?;
|
|
footer.set_class_name("footer");
|
|
|
|
let container = document.create_element("div")?;
|
|
container.set_class_name("container");
|
|
|
|
let text = document.create_element("p")?;
|
|
text.set_inner_html("Built with <span class=\"rust-love\">🦀 Rust</span> + WebAssembly • Deployed via GitOps");
|
|
|
|
let links = document.create_element("div")?;
|
|
links.set_class_name("footer-links");
|
|
|
|
let link_data = vec![
|
|
("Traefik Dashboard", "https://dc.railwayka.ru/dashboard/"),
|
|
("Gitea", "https://git.dc.railwayka.ru"),
|
|
("Woodpecker CI", "https://cicd.dc.railwayka.ru"),
|
|
];
|
|
|
|
for (name, url) in link_data {
|
|
let link = document.create_element("a")?;
|
|
link.set_attribute("href", url)?;
|
|
link.set_attribute("target", "_blank")?;
|
|
link.set_attribute("rel", "noopener noreferrer")?;
|
|
link.set_text_content(Some(name));
|
|
links.append_child(&link)?;
|
|
}
|
|
|
|
container.append_child(&text)?;
|
|
container.append_child(&links)?;
|
|
footer.append_child(&container)?;
|
|
|
|
Ok(footer)
|
|
}
|
|
|
|
fn setup_scroll_animations(document: &Document) -> Result<(), JsValue> {
|
|
// Create intersection observer callback
|
|
let callback = Closure::wrap(Box::new(move |entries: js_sys::Array, _observer: IntersectionObserver| {
|
|
for entry in entries.iter() {
|
|
if let Ok(entry) = entry.dyn_into::<IntersectionObserverEntry>() {
|
|
if entry.is_intersecting() {
|
|
if let Some(target) = entry.target().dyn_ref::<Element>() {
|
|
let delay = target.get_attribute("data-delay")
|
|
.and_then(|d| d.parse::<u32>().ok())
|
|
.unwrap_or(0);
|
|
|
|
target.class_list().add_1("animate-in").ok();
|
|
target.set_attribute("style", &format!("animation-delay: {}ms", delay)).ok();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}) as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);
|
|
|
|
// Create observer with options
|
|
let options = IntersectionObserverInit::new();
|
|
options.set_threshold(&JsValue::from_f64(0.1));
|
|
|
|
let observer = IntersectionObserver::new_with_options(
|
|
callback.as_ref().unchecked_ref(),
|
|
&options,
|
|
)?;
|
|
|
|
// Observe all elements with data-animate attribute
|
|
let elements = document.query_selector_all("[data-animate]")?;
|
|
for i in 0..elements.length() {
|
|
if let Some(node) = elements.item(i) {
|
|
if let Some(element) = node.dyn_ref::<Element>() {
|
|
observer.observe(element);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prevent callback from being dropped
|
|
callback.forget();
|
|
|
|
Ok(())
|
|
}
|