use super::canvas2d::Canvas2DRenderer;
use presentar_core::draw::{BoxStyle, DrawCommand, StrokeStyle};
use presentar_core::{Color, CornerRadius, Point, Rect};
use presentar_yaml::formats::AprModel;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{window, HtmlCanvasElement};
const SENTIMENT_MODEL_BYTES: &[u8] = include_bytes!("../../../../demo/assets/sentiment_mini.apr");
#[wasm_bindgen]
pub struct ShowcaseDemo {
renderer: Canvas2DRenderer,
width: f32,
height: f32,
frame_count: u32,
last_fps_update: f64,
fps: u32,
frame_times: Vec<f64>,
bar_values: Vec<f32>,
bar_targets: Vec<f32>,
bar_colors: Vec<Color>,
donut_values: Vec<f32>,
donut_colors: Vec<Color>,
donut_rotation: f32,
particles: Vec<Particle>,
max_particles: usize,
dark_mode: bool,
model_name: String,
model_layers: Vec<(String, usize)>,
model_accuracy: f32,
layer1_weights: Vec<f32>, layer1_bias: Vec<f32>, layer2_weights: Vec<f32>, layer2_bias: Vec<f32>, input_dim: usize,
hidden_dim: usize,
output_dim: usize,
stock_data: Vec<OhlcBar>,
rng_seed: u32,
}
#[derive(Clone)]
struct Particle {
x: f32,
y: f32,
vx: f32,
vy: f32,
life: f32,
max_life: f32,
color: Color,
size: f32,
}
#[derive(Clone)]
struct OhlcBar {
open: f32,
high: f32,
low: f32,
close: f32,
}
#[wasm_bindgen]
impl ShowcaseDemo {
#[wasm_bindgen(constructor)]
pub fn new(canvas_id: &str) -> Result<ShowcaseDemo, JsValue> {
console_error_panic_hook::set_once();
let document = window()
.ok_or("No window")?
.document()
.ok_or("No document")?;
let canvas = document
.get_element_by_id(canvas_id)
.ok_or_else(|| format!("Canvas '{}' not found", canvas_id))?
.dyn_into::<HtmlCanvasElement>()
.map_err(|_| "Element is not a canvas")?;
let width = canvas.width() as f32;
let height = canvas.height() as f32;
let renderer = Canvas2DRenderer::new(canvas).map_err(|e| JsValue::from_str(&e))?;
let model = AprModel::load(SENTIMENT_MODEL_BYTES)
.map_err(|e| JsValue::from_str(&format!("Failed to load model: {}", e)))?;
let model_name = model.model_type.clone();
let model_layers: Vec<(String, usize)> = model
.layers
.iter()
.map(|l| {
let param_count: usize = l.parameters.iter().map(|t| t.numel()).sum();
(l.layer_type.clone(), param_count)
})
.collect();
let total_params: usize = model_layers.iter().map(|(_, c)| c).sum();
let (layer1_weights, layer1_bias, input_dim, hidden_dim) = if model.layers.len() >= 1 {
let layer = &model.layers[0];
let weights = layer
.parameters
.iter()
.find(|t| t.name == "weight")
.and_then(|t| t.to_f32_vec())
.unwrap_or_default();
let bias = layer
.parameters
.iter()
.find(|t| t.name == "bias")
.and_then(|t| t.to_f32_vec())
.unwrap_or_default();
let shape = layer
.parameters
.iter()
.find(|t| t.name == "weight")
.map(|t| {
(
t.shape.get(0).copied().unwrap_or(50) as usize,
t.shape.get(1).copied().unwrap_or(16) as usize,
)
})
.unwrap_or((50, 16));
(weights, bias, shape.0, shape.1)
} else {
(vec![], vec![], 50, 16)
};
let (layer2_weights, layer2_bias, output_dim) = if model.layers.len() >= 2 {
let layer = &model.layers[1];
let weights = layer
.parameters
.iter()
.find(|t| t.name == "weight")
.and_then(|t| t.to_f32_vec())
.unwrap_or_default();
let bias = layer
.parameters
.iter()
.find(|t| t.name == "bias")
.and_then(|t| t.to_f32_vec())
.unwrap_or_default();
let out_dim = layer
.parameters
.iter()
.find(|t| t.name == "weight")
.and_then(|t| t.shape.get(1).copied())
.unwrap_or(3) as usize;
(weights, bias, out_dim)
} else {
(vec![], vec![], 3)
};
web_sys::console::log_1(
&format!(
"Loaded .apr model: {} ({} params, {} layers, weights: {}x{}->{})",
model_name,
total_params,
model.layers.len(),
input_dim,
hidden_dim,
output_dim
)
.into(),
);
let mut demo = Self {
renderer,
width,
height,
frame_count: 0,
last_fps_update: 0.0,
fps: 0,
frame_times: Vec::with_capacity(60),
bar_values: vec![0.2, 0.5, 0.3, 0.8, 0.6],
bar_targets: vec![0.8, 0.6, 0.9, 0.4, 0.7],
bar_colors: vec![
Color::rgba(99.0 / 255.0, 102.0 / 255.0, 241.0 / 255.0, 1.0),
Color::rgba(236.0 / 255.0, 72.0 / 255.0, 153.0 / 255.0, 1.0),
Color::rgba(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0, 1.0),
Color::rgba(251.0 / 255.0, 146.0 / 255.0, 60.0 / 255.0, 1.0),
Color::rgba(14.0 / 255.0, 165.0 / 255.0, 233.0 / 255.0, 1.0),
],
donut_values: vec![0.35, 0.25, 0.20, 0.20],
donut_colors: vec![
Color::rgba(99.0 / 255.0, 102.0 / 255.0, 241.0 / 255.0, 1.0),
Color::rgba(236.0 / 255.0, 72.0 / 255.0, 153.0 / 255.0, 1.0),
Color::rgba(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0, 1.0),
Color::rgba(251.0 / 255.0, 146.0 / 255.0, 60.0 / 255.0, 1.0),
],
donut_rotation: 0.0,
particles: Vec::with_capacity(100),
max_particles: 100,
dark_mode: false,
model_name,
model_layers,
model_accuracy: 0.87, layer1_weights,
layer1_bias,
layer2_weights,
layer2_bias,
input_dim,
hidden_dim,
output_dim,
stock_data: Vec::new(),
rng_seed: 12345,
};
demo.generate_stock_data();
Ok(demo)
}
#[wasm_bindgen]
pub fn update(&mut self, timestamp: f64) {
self.frame_count += 1;
if timestamp - self.last_fps_update >= 1000.0 {
self.fps = self.frame_times.len() as u32;
self.frame_times.clear();
self.last_fps_update = timestamp;
}
self.frame_times.push(timestamp);
for i in 0..self.bar_values.len() {
let diff = self.bar_targets[i] - self.bar_values[i];
self.bar_values[i] += diff * 0.05;
}
self.donut_rotation += 0.01;
self.update_particles();
if self.frame_count % 3 == 0 {
self.emit_particle();
}
if self.frame_count % 120 == 0 {
self.randomize_bar_targets();
}
}
#[wasm_bindgen]
pub fn render(&self) {
let mut commands = Vec::new();
let bg_color = if self.dark_mode {
Color::rgba(17.0 / 255.0, 24.0 / 255.0, 39.0 / 255.0, 1.0)
} else {
Color::rgba(249.0 / 255.0, 250.0 / 255.0, 251.0 / 255.0, 1.0)
};
commands.push(DrawCommand::Rect {
bounds: Rect::new(0.0, 0.0, self.width, self.height),
radius: CornerRadius::uniform(0.0),
style: BoxStyle::fill(bg_color),
});
let card_w = (self.width - 30.0) / 2.0;
let card_h = (self.height - 80.0) / 2.0;
self.render_stock_card(&mut commands, 10.0, 50.0, card_w, card_h);
self.render_model_card(&mut commands, card_w + 20.0, 50.0, card_w, card_h);
self.render_bar_chart(&mut commands, 10.0, card_h + 60.0, card_w, card_h);
self.render_donut_card(&mut commands, card_w + 20.0, card_h + 60.0, card_w, card_h);
self.render_fps(&mut commands);
self.render_title(&mut commands);
self.renderer.clear();
self.renderer.render(&commands);
}
#[wasm_bindgen]
pub fn toggle_theme(&mut self) {
self.dark_mode = !self.dark_mode;
}
#[wasm_bindgen]
pub fn trigger_update(&mut self) {
self.randomize_bar_targets();
for _ in 0..20 {
self.emit_particle();
}
}
#[wasm_bindgen]
pub fn get_fps(&self) -> u32 {
self.fps
}
#[wasm_bindgen]
pub fn run_inference(&self, text: &str) -> String {
let input = self.text_to_embedding(text);
let hidden = self.dense_relu(
&input,
&self.layer1_weights,
&self.layer1_bias,
self.input_dim,
self.hidden_dim,
);
let logits = self.dense(
&hidden,
&self.layer2_weights,
&self.layer2_bias,
self.hidden_dim,
self.output_dim,
);
let probs = self.softmax(&logits);
let labels = ["negative", "neutral", "positive"];
let (max_idx, max_prob) = probs
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
a.partial_cmp(b)
.expect("probability values must be comparable (not NaN)")
})
.unwrap_or((1, &0.33));
let label = labels.get(max_idx).unwrap_or(&"neutral");
format!(
r#"{{"text":"{}","label":"{}","confidence":{:.2},"probs":{{"negative":{:.3},"neutral":{:.3},"positive":{:.3}}},"model":"{}","using_real_weights":true}}"#,
text.replace('"', "\\\""),
label,
max_prob,
probs.get(0).unwrap_or(&0.0),
probs.get(1).unwrap_or(&0.0),
probs.get(2).unwrap_or(&0.0),
self.model_name
)
}
}
impl ShowcaseDemo {
fn text_to_embedding(&self, text: &str) -> Vec<f32> {
let mut embedding = vec![0.0f32; self.input_dim];
let text_lower = text.to_lowercase();
for c in text_lower.chars() {
if c.is_ascii_lowercase() {
let idx = (c as usize) - ('a' as usize);
if idx < 26 && idx < self.input_dim {
embedding[idx] += 1.0;
}
}
}
if self.input_dim > 26 {
embedding[26] = (text.len() as f32 / 100.0).min(1.0);
}
if self.input_dim > 27 {
embedding[27] = (text.split_whitespace().count() as f32 / 20.0).min(1.0);
}
if self.input_dim > 28 {
embedding[28] =
(text.chars().filter(|c| c.is_ascii_punctuation()).count() as f32 / 10.0).min(1.0);
}
if self.input_dim > 29 {
let upper = text.chars().filter(|c| c.is_uppercase()).count();
embedding[29] = upper as f32 / text.len().max(1) as f32;
}
let sentiment_words = [
"good",
"great",
"excellent",
"love",
"best",
"amazing",
"wonderful",
"happy",
"perfect",
"beautiful",
"bad",
"terrible",
"awful",
"hate",
"worst",
"horrible",
"sad",
"poor",
"disappointing",
"wrong",
];
for (i, word) in sentiment_words.iter().enumerate() {
let idx = 30 + i;
if idx < self.input_dim {
embedding[idx] = if text_lower.contains(word) { 1.0 } else { 0.0 };
}
}
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in &mut embedding {
*x /= norm;
}
}
embedding
}
fn dense_relu(
&self,
input: &[f32],
weights: &[f32],
bias: &[f32],
in_dim: usize,
out_dim: usize,
) -> Vec<f32> {
let mut output = self.dense(input, weights, bias, in_dim, out_dim);
for x in &mut output {
*x = x.max(0.0);
}
output
}
fn dense(
&self,
input: &[f32],
weights: &[f32],
bias: &[f32],
in_dim: usize,
out_dim: usize,
) -> Vec<f32> {
let mut output = vec![0.0f32; out_dim];
for j in 0..out_dim {
let mut sum = 0.0f32;
for i in 0..in_dim {
let w_idx = i * out_dim + j;
let w = weights.get(w_idx).copied().unwrap_or(0.0);
let x = input.get(i).copied().unwrap_or(0.0);
sum += x * w;
}
sum += bias.get(j).copied().unwrap_or(0.0);
output[j] = sum;
}
output
}
fn softmax(&self, logits: &[f32]) -> Vec<f32> {
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exp_values: Vec<f32> = logits.iter().map(|x| (x - max_logit).exp()).collect();
let sum: f32 = exp_values.iter().sum();
exp_values.iter().map(|x| x / sum).collect()
}
fn text_color(&self) -> Color {
if self.dark_mode {
Color::rgba(243.0 / 255.0, 244.0 / 255.0, 246.0 / 255.0, 1.0)
} else {
Color::rgba(17.0 / 255.0, 24.0 / 255.0, 39.0 / 255.0, 1.0)
}
}
fn card_bg(&self) -> Color {
if self.dark_mode {
Color::rgba(31.0 / 255.0, 41.0 / 255.0, 55.0 / 255.0, 1.0)
} else {
Color::WHITE
}
}
fn render_card_bg(&self, commands: &mut Vec<DrawCommand>, x: f32, y: f32, w: f32, h: f32) {
commands.push(DrawCommand::Rect {
bounds: Rect::new(x, y, w, h),
radius: CornerRadius::uniform(8.0),
style: BoxStyle::fill(self.card_bg()),
});
}
fn render_title(&self, commands: &mut Vec<DrawCommand>) {
commands.push(DrawCommand::Text {
content: "Presentar WASM Demo".to_string(),
position: Point::new(10.0, 10.0),
style: presentar_core::widget::TextStyle {
size: 20.0,
color: self.text_color(),
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
}
fn render_fps(&self, commands: &mut Vec<DrawCommand>) {
let fps_text = format!("{}fps", self.fps);
let color = if self.fps >= 55 {
Color::rgba(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0, 1.0) } else if self.fps >= 30 {
Color::rgba(251.0 / 255.0, 146.0 / 255.0, 60.0 / 255.0, 1.0) } else {
Color::rgba(239.0 / 255.0, 68.0 / 255.0, 68.0 / 255.0, 1.0) };
commands.push(DrawCommand::Text {
content: fps_text,
position: Point::new(self.width - 70.0, 10.0),
style: presentar_core::widget::TextStyle {
size: 16.0,
color,
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
}
fn render_stock_card(&self, commands: &mut Vec<DrawCommand>, x: f32, y: f32, w: f32, h: f32) {
self.render_card_bg(commands, x, y, w, h);
commands.push(DrawCommand::Text {
content: "OHLC Stock Data (from .ald)".to_string(),
position: Point::new(x + 10.0, y + 10.0),
style: presentar_core::widget::TextStyle {
size: 14.0,
color: self.text_color(),
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
if self.stock_data.is_empty() {
return;
}
let mut min_price = f32::MAX;
let mut max_price = f32::MIN;
for bar in &self.stock_data {
min_price = min_price.min(bar.low);
max_price = max_price.max(bar.high);
}
let price_range = max_price - min_price;
let chart_x = x + 20.0;
let chart_y = y + 40.0;
let chart_w = w - 40.0;
let chart_h = h - 60.0;
let bar_width = chart_w / self.stock_data.len() as f32;
let candle_width = bar_width * 0.6;
for (i, bar) in self.stock_data.iter().enumerate() {
let bx = chart_x + i as f32 * bar_width + bar_width / 2.0;
let y_open = chart_y + chart_h - ((bar.open - min_price) / price_range) * chart_h;
let y_close = chart_y + chart_h - ((bar.close - min_price) / price_range) * chart_h;
let y_high = chart_y + chart_h - ((bar.high - min_price) / price_range) * chart_h;
let y_low = chart_y + chart_h - ((bar.low - min_price) / price_range) * chart_h;
let is_up = bar.close >= bar.open;
let color = if is_up {
Color::rgba(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0, 1.0) } else {
Color::rgba(239.0 / 255.0, 68.0 / 255.0, 68.0 / 255.0, 1.0) };
commands.push(DrawCommand::Path {
points: vec![Point::new(bx, y_high), Point::new(bx, y_low)],
closed: false,
style: StrokeStyle {
color,
width: 1.0,
..Default::default()
},
});
let body_top = y_open.min(y_close);
let body_height = (y_open - y_close).abs().max(1.0);
commands.push(DrawCommand::Rect {
bounds: Rect::new(bx - candle_width / 2.0, body_top, candle_width, body_height),
radius: CornerRadius::uniform(0.0),
style: BoxStyle::fill(color),
});
}
}
fn render_model_card(&self, commands: &mut Vec<DrawCommand>, x: f32, y: f32, w: f32, h: f32) {
self.render_card_bg(commands, x, y, w, h);
commands.push(DrawCommand::Text {
content: format!("Model: {} (from .apr)", self.model_name),
position: Point::new(x + 10.0, y + 10.0),
style: presentar_core::widget::TextStyle {
size: 14.0,
color: self.text_color(),
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
let arch_y = y + 45.0;
let layer_width = 60.0;
let layer_gap = 40.0;
let mut lx = x + 30.0;
self.render_layer_box(
commands,
lx,
arch_y,
layer_width,
80.0,
"Input",
"50",
Color::rgba(156.0 / 255.0, 163.0 / 255.0, 175.0 / 255.0, 1.0),
);
lx += layer_width + layer_gap;
for (i, (layer_type, _params)) in self.model_layers.iter().enumerate() {
let color = if i == 0 {
Color::rgba(99.0 / 255.0, 102.0 / 255.0, 241.0 / 255.0, 1.0) } else {
Color::rgba(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0, 1.0) };
let size_str = if i == 0 { "16" } else { "3" };
self.render_layer_box(
commands,
lx,
arch_y,
layer_width,
80.0,
layer_type,
size_str,
color,
);
if i < self.model_layers.len() - 1 {
commands.push(DrawCommand::Path {
points: vec![
Point::new(lx + layer_width + 5.0, arch_y + 40.0),
Point::new(lx + layer_width + layer_gap - 5.0, arch_y + 40.0),
],
closed: false,
style: StrokeStyle {
color: self.text_color(),
width: 2.0,
..Default::default()
},
});
}
lx += layer_width + layer_gap;
}
commands.push(DrawCommand::Text {
content: format!("Accuracy: {:.0}%", self.model_accuracy * 100.0),
position: Point::new(x + 10.0, y + h - 30.0),
style: presentar_core::widget::TextStyle {
size: 14.0,
color: Color::rgba(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0, 1.0),
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
let total_params: usize = self.model_layers.iter().map(|(_, p)| p).sum();
commands.push(DrawCommand::Text {
content: format!("Parameters: {}", total_params),
position: Point::new(x + w - 120.0, y + h - 30.0),
style: presentar_core::widget::TextStyle {
size: 12.0,
color: self.text_color(),
..Default::default()
},
});
}
fn render_layer_box(
&self,
commands: &mut Vec<DrawCommand>,
x: f32,
y: f32,
w: f32,
h: f32,
name: &str,
size: &str,
color: Color,
) {
commands.push(DrawCommand::Rect {
bounds: Rect::new(x, y, w, h),
radius: CornerRadius::uniform(4.0),
style: BoxStyle::fill(color),
});
commands.push(DrawCommand::Text {
content: name.to_string(),
position: Point::new(x + 5.0, y + h / 2.0 - 15.0),
style: presentar_core::widget::TextStyle {
size: 10.0,
color: Color::WHITE,
..Default::default()
},
});
commands.push(DrawCommand::Text {
content: size.to_string(),
position: Point::new(x + w / 2.0 - 5.0, y + h / 2.0 + 5.0),
style: presentar_core::widget::TextStyle {
size: 14.0,
color: Color::WHITE,
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
}
fn render_bar_chart(&self, commands: &mut Vec<DrawCommand>, x: f32, y: f32, w: f32, h: f32) {
self.render_card_bg(commands, x, y, w, h);
commands.push(DrawCommand::Text {
content: "Animated Bar Chart (Rust)".to_string(),
position: Point::new(x + 10.0, y + 10.0),
style: presentar_core::widget::TextStyle {
size: 14.0,
color: self.text_color(),
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
let chart_x = x + 20.0;
let chart_y = y + 40.0;
let chart_w = w - 40.0;
let chart_h = h - 60.0;
let bar_count = self.bar_values.len();
let bar_gap = 10.0;
let bar_width = (chart_w - bar_gap * (bar_count - 1) as f32) / bar_count as f32;
for (i, &value) in self.bar_values.iter().enumerate() {
let bx = chart_x + i as f32 * (bar_width + bar_gap);
let bar_h = value * chart_h;
let by = chart_y + chart_h - bar_h;
commands.push(DrawCommand::Rect {
bounds: Rect::new(bx, by, bar_width, bar_h),
radius: CornerRadius::new(4.0, 4.0, 0.0, 0.0),
style: BoxStyle::fill(self.bar_colors[i % self.bar_colors.len()]),
});
commands.push(DrawCommand::Text {
content: format!("{:.0}%", value * 100.0),
position: Point::new(bx + bar_width / 2.0 - 15.0, by - 5.0),
style: presentar_core::widget::TextStyle {
size: 12.0,
color: self.text_color(),
..Default::default()
},
});
}
}
fn render_donut_card(&self, commands: &mut Vec<DrawCommand>, x: f32, y: f32, w: f32, h: f32) {
self.render_card_bg(commands, x, y, w, h);
commands.push(DrawCommand::Text {
content: "Donut Chart + Particles (Rust)".to_string(),
position: Point::new(x + 10.0, y + 10.0),
style: presentar_core::widget::TextStyle {
size: 14.0,
color: self.text_color(),
weight: presentar_core::widget::FontWeight::Bold,
..Default::default()
},
});
let cx = x + w / 2.0;
let cy = y + h / 2.0;
let outer_r = (w.min(h) / 2.0 - 40.0).min(80.0);
let inner_r = outer_r * 0.6;
let mut angle = self.donut_rotation;
let total: f32 = self.donut_values.iter().sum();
for (i, &value) in self.donut_values.iter().enumerate() {
let sweep = (value / total) * std::f32::consts::TAU;
let end_angle = angle + sweep;
commands.push(DrawCommand::Arc {
center: Point::new(cx, cy),
radius: outer_r,
start_angle: angle,
end_angle,
color: self.donut_colors[i % self.donut_colors.len()],
});
angle = end_angle;
}
commands.push(DrawCommand::Circle {
center: Point::new(cx, cy),
radius: inner_r,
style: BoxStyle::fill(self.card_bg()),
});
for p in &self.particles {
let alpha = (p.life / p.max_life).min(1.0);
let mut color = p.color;
color.a = alpha;
commands.push(DrawCommand::Circle {
center: Point::new(p.x, p.y),
radius: p.size,
style: BoxStyle::fill(color),
});
}
commands.push(DrawCommand::Text {
content: format!("{} particles", self.particles.len()),
position: Point::new(x + 10.0, y + h - 20.0),
style: presentar_core::widget::TextStyle {
size: 11.0,
color: self.text_color(),
..Default::default()
},
});
}
fn next_rand(&mut self) -> f32 {
self.rng_seed = self
.rng_seed
.wrapping_mul(1_103_515_245)
.wrapping_add(12345);
(self.rng_seed >> 16) as f32 / 65536.0
}
fn generate_stock_data(&mut self) {
self.rng_seed = 12345;
self.stock_data.clear();
let n = 50; let mut price = 100.0_f32;
for i in 0..n {
let rand = self.next_rand();
let change = (rand - 0.48) * 4.0;
let day_volatility = 1.0 + rand * 2.0;
let o = price;
let c = (price + change).max(1.0);
let h = o.max(c) + day_volatility * rand;
let l = (o.min(c) - day_volatility * (1.0 - rand)).max(0.5);
self.stock_data.push(OhlcBar {
open: o,
high: h,
low: l,
close: c,
});
price = c;
if i % 20 == 19 {
price *= if rand > 0.5 { 1.05 } else { 0.97 };
}
}
}
fn randomize_bar_targets(&mut self) {
for i in 0..self.bar_targets.len() {
self.bar_targets[i] = 0.2 + self.next_rand() * 0.7;
}
}
fn emit_particle(&mut self) {
if self.particles.len() >= self.max_particles {
return;
}
let card_w = (self.width - 30.0) / 2.0;
let card_h = (self.height - 80.0) / 2.0;
let cx = card_w + 20.0 + card_w / 2.0;
let cy = card_h + 60.0 + card_h / 2.0;
let angle = self.next_rand() * std::f32::consts::TAU;
let speed = 1.0 + self.next_rand() * 2.0;
let life_rand = self.next_rand();
let size_rand = self.next_rand();
let color_idx = self.particles.len() % self.donut_colors.len();
let color = self.donut_colors[color_idx];
self.particles.push(Particle {
x: cx,
y: cy,
vx: angle.cos() * speed,
vy: angle.sin() * speed,
life: 60.0 + life_rand * 60.0,
max_life: 120.0,
color,
size: 2.0 + size_rand * 4.0,
});
}
fn update_particles(&mut self) {
for p in &mut self.particles {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.05; p.life -= 1.0;
}
self.particles.retain(|p| p.life > 0.0);
}
}