use crate::chains::{PricePoint, TokenHolder, VolumePoint};
use textplots::{Chart, Plot, Shape};
#[derive(Debug, Clone)]
pub struct ChartConfig {
pub width: u32,
pub height: u32,
pub title: Option<String>,
pub show_labels: bool,
}
impl Default for ChartConfig {
fn default() -> Self {
Self {
width: 60,
height: 15,
title: None,
show_labels: true,
}
}
}
pub fn render_price_chart(price_history: &[PricePoint], width: u32, height: u32) -> String {
if price_history.is_empty() {
return "No price data available".to_string();
}
let mut output = String::new();
let min_price = price_history
.iter()
.map(|p| p.price)
.fold(f64::INFINITY, f64::min);
let max_price = price_history
.iter()
.map(|p| p.price)
.fold(f64::NEG_INFINITY, f64::max);
let min_time = price_history.iter().map(|p| p.timestamp).min().unwrap_or(0);
let max_time = price_history.iter().map(|p| p.timestamp).max().unwrap_or(0);
let points: Vec<(f32, f32)> = price_history
.iter()
.map(|p| {
let x = (p.timestamp - min_time) as f32;
let y = p.price as f32;
(x, y)
})
.collect();
if points.is_empty() {
return "No price data available".to_string();
}
let x_max = (max_time - min_time) as f32;
let x_min = 0.0_f32;
let chart_str = Chart::new(width, height, x_min, x_max)
.lineplot(&Shape::Lines(&points))
.to_string();
output.push_str(&format!("Price (${:.4} - ${:.4})\n", min_price, max_price));
output.push_str(&chart_str);
let time_range_hours = (max_time - min_time) as f64 / 3600.0;
if time_range_hours <= 24.0 {
output.push_str(&format!(
" {:>width$}\n",
format!("{:.0}h ago -> now", time_range_hours),
width = width as usize - 5
));
} else {
let days = time_range_hours / 24.0;
output.push_str(&format!(
" {:>width$}\n",
format!("{:.0}d ago -> now", days),
width = width as usize - 5
));
}
output
}
pub fn render_volume_chart(volume_history: &[VolumePoint], width: u32, height: u32) -> String {
if volume_history.is_empty() {
return "No volume data available".to_string();
}
let mut output = String::new();
let max_volume = volume_history
.iter()
.map(|v| v.volume)
.fold(f64::NEG_INFINITY, f64::max);
let total_volume: f64 = volume_history.iter().map(|v| v.volume).sum();
let max_vol_formatted = crate::display::format_usd(max_volume);
let total_vol_formatted = crate::display::format_usd(total_volume);
output.push_str(&format!(
"Volume (max: {}, total: {})\n",
max_vol_formatted, total_vol_formatted
));
let min_time = volume_history
.iter()
.map(|v| v.timestamp)
.min()
.unwrap_or(0);
let max_time = volume_history
.iter()
.map(|v| v.timestamp)
.max()
.unwrap_or(0);
let points: Vec<(f32, f32)> = volume_history
.iter()
.map(|v| {
let x = (v.timestamp - min_time) as f32;
let y = v.volume as f32;
(x, y)
})
.collect();
let x_max = (max_time - min_time) as f32;
let x_min = 0.0_f32;
let chart_str = Chart::new(width, height, x_min, x_max)
.lineplot(&Shape::Bars(&points))
.to_string();
output.push_str(&chart_str);
output
}
pub fn render_holder_distribution(holders: &[TokenHolder]) -> String {
if holders.is_empty() {
return "No holder data available".to_string();
}
let mut output = String::new();
output.push_str("Top Holders\n");
output.push_str(&"=".repeat(50));
output.push('\n');
let max_bar_width = 20;
for holder in holders.iter().take(10) {
let addr_display = super::text::abbreviate(&holder.address);
let bar_width = ((holder.percentage / 100.0) * max_bar_width as f64).round() as usize;
let bar_width = bar_width.min(max_bar_width);
let filled = "█".repeat(bar_width);
let empty = "░".repeat(max_bar_width - bar_width);
output.push_str(&format!(
"{:>2}. {} {:>6.2}% {}{}\n",
holder.rank, addr_display, holder.percentage, filled, empty
));
}
if holders.len() >= 10 {
let top_10_total: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
output.push_str(&"-".repeat(50));
output.push('\n');
output.push_str(&format!("Top 10 control: {:.1}% of supply\n", top_10_total));
}
output
}
pub fn render_analytics_dashboard(
price_history: &[PricePoint],
volume_history: &[VolumePoint],
holders: &[TokenHolder],
token_symbol: &str,
chain: &str,
) -> String {
let mut output = String::new();
output.push_str(&format!("Token Analytics: {} on {}\n", token_symbol, chain));
output.push_str(&"=".repeat(60));
output.push_str("\n\n");
if !price_history.is_empty() {
output.push_str(&render_price_chart(price_history, 60, 12));
output.push('\n');
}
if !volume_history.is_empty() {
output.push_str(&render_volume_chart(volume_history, 60, 8));
output.push('\n');
}
if !holders.is_empty() {
output.push_str(&render_holder_distribution(holders));
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_price_chart_empty() {
let result = render_price_chart(&[], 60, 10);
assert!(result.contains("No price data"));
}
#[test]
fn test_render_price_chart_with_data() {
let history = vec![
PricePoint {
timestamp: 0,
price: 100.0,
},
PricePoint {
timestamp: 3600,
price: 105.0,
},
PricePoint {
timestamp: 7200,
price: 102.0,
},
];
let result = render_price_chart(&history, 60, 10);
assert!(!result.is_empty());
assert!(result.contains("Price"));
}
#[test]
fn test_render_volume_chart_empty() {
let result = render_volume_chart(&[], 60, 10);
assert!(result.contains("No volume data"));
}
#[test]
fn test_render_volume_chart_with_data() {
let history = vec![
VolumePoint {
timestamp: 0,
volume: 1000000.0,
},
VolumePoint {
timestamp: 3600,
volume: 1500000.0,
},
];
let result = render_volume_chart(&history, 60, 10);
assert!(!result.is_empty());
assert!(result.contains("Volume"));
}
#[test]
fn test_render_holder_distribution_empty() {
let result = render_holder_distribution(&[]);
assert!(result.contains("No holder data"));
}
#[test]
fn test_render_holder_distribution_with_data() {
let holders = vec![
TokenHolder {
address: "0x1234567890123456789012345678901234567890".to_string(),
balance: "1000000".to_string(),
formatted_balance: "1M".to_string(),
percentage: 25.0,
rank: 1,
},
TokenHolder {
address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd".to_string(),
balance: "500000".to_string(),
formatted_balance: "500K".to_string(),
percentage: 12.5,
rank: 2,
},
];
let result = render_holder_distribution(&holders);
assert!(result.contains("Top Holders"));
assert!(result.contains("25.00%"));
assert!(result.contains("12.50%"));
assert!(result.contains("█")); }
#[test]
fn test_format_large_number() {
assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
assert_eq!(crate::display::format_large_number(500.0), "500.00");
}
#[test]
fn test_chart_config_default() {
let config = ChartConfig::default();
assert_eq!(config.width, 60);
assert_eq!(config.height, 15);
assert!(config.show_labels);
}
#[test]
fn test_render_analytics_dashboard() {
let prices = vec![PricePoint {
timestamp: 0,
price: 1.0,
}];
let volumes = vec![VolumePoint {
timestamp: 0,
volume: 1000.0,
}];
let holders = vec![TokenHolder {
address: "0x1234567890123456789012345678901234567890".to_string(),
balance: "1000".to_string(),
formatted_balance: "1K".to_string(),
percentage: 50.0,
rank: 1,
}];
let result = render_analytics_dashboard(&prices, &volumes, &holders, "TEST", "ethereum");
assert!(result.contains("Token Analytics: TEST on ethereum"));
}
#[test]
fn test_price_chart_multiday_range() {
let prices: Vec<PricePoint> = (0..50)
.map(|i| PricePoint {
timestamp: i * 7200, price: 1.0 + (i as f64) * 0.01,
})
.collect();
let chart = render_price_chart(&prices, 60, 15);
assert!(chart.contains("d ago -> now"));
}
#[test]
fn test_holder_distribution_with_10_holders() {
let holders: Vec<TokenHolder> = (1..=12)
.map(|i| TokenHolder {
address: format!("0x{:040}", i),
balance: format!("{}", 1000 - i * 50),
formatted_balance: format!("{}K", (1000 - i * 50) / 1000),
percentage: 10.0 - (i as f64) * 0.5,
rank: i as u32,
})
.collect();
let chart = render_holder_distribution(&holders);
assert!(chart.contains("Top 10 control:"));
assert!(chart.contains("% of supply"));
}
}