Skip to main content

scope/display/
charts.rs

1//! # ASCII Chart Rendering
2//!
3//! This module provides ASCII chart rendering for terminal display,
4//! similar to the visualization style used by `btm` (bottom).
5//!
6//! ## Features
7//!
8//! - Line charts for price history
9//! - Bar charts for volume data
10//! - Distribution charts for holder concentration
11//!
12//! ## Usage
13//!
14//! ```rust
15//! use scope::display::charts::{render_price_chart, ChartConfig};
16//! use scope::chains::PricePoint;
17//!
18//! let history = vec![
19//!     PricePoint { timestamp: 0, price: 100.0 },
20//!     PricePoint { timestamp: 3600, price: 105.0 },
21//! ];
22//!
23//! let chart = render_price_chart(&history, 60, 10);
24//! println!("{}", chart);
25//! ```
26
27use crate::chains::{PricePoint, TokenHolder, VolumePoint};
28use textplots::{Chart, Plot, Shape};
29
30/// Configuration for chart rendering.
31#[derive(Debug, Clone)]
32pub struct ChartConfig {
33    /// Width of the chart in characters.
34    pub width: u32,
35    /// Height of the chart in characters.
36    pub height: u32,
37    /// Title for the chart.
38    pub title: Option<String>,
39    /// Whether to show axis labels.
40    pub show_labels: bool,
41}
42
43impl Default for ChartConfig {
44    fn default() -> Self {
45        Self {
46            width: 60,
47            height: 15,
48            title: None,
49            show_labels: true,
50        }
51    }
52}
53
54/// Renders a price chart as ASCII art.
55///
56/// # Arguments
57///
58/// * `price_history` - Vector of price points over time
59/// * `width` - Chart width in characters
60/// * `height` - Chart height in characters
61///
62/// # Returns
63///
64/// Returns a string containing the ASCII chart.
65pub fn render_price_chart(price_history: &[PricePoint], width: u32, height: u32) -> String {
66    if price_history.is_empty() {
67        return "No price data available".to_string();
68    }
69
70    let mut output = String::new();
71
72    // Calculate price range for labels
73    let min_price = price_history
74        .iter()
75        .map(|p| p.price)
76        .fold(f64::INFINITY, f64::min);
77    let max_price = price_history
78        .iter()
79        .map(|p| p.price)
80        .fold(f64::NEG_INFINITY, f64::max);
81
82    // Calculate time range
83    let min_time = price_history.iter().map(|p| p.timestamp).min().unwrap_or(0);
84    let max_time = price_history.iter().map(|p| p.timestamp).max().unwrap_or(0);
85
86    // Convert to textplots format (f32 points)
87    let points: Vec<(f32, f32)> = price_history
88        .iter()
89        .map(|p| {
90            let x = (p.timestamp - min_time) as f32;
91            let y = p.price as f32;
92            (x, y)
93        })
94        .collect();
95
96    if points.is_empty() {
97        return "No price data available".to_string();
98    }
99
100    // Render chart to string
101    let x_max = (max_time - min_time) as f32;
102    let x_min = 0.0_f32;
103
104    // Capture chart output
105    let chart_str = Chart::new(width, height, x_min, x_max)
106        .lineplot(&Shape::Lines(&points))
107        .to_string();
108
109    // Format the output with title and labels
110    output.push_str(&format!("Price (${:.4} - ${:.4})\n", min_price, max_price));
111    output.push_str(&chart_str);
112
113    // Add time labels
114    let time_range_hours = (max_time - min_time) as f64 / 3600.0;
115    if time_range_hours <= 24.0 {
116        output.push_str(&format!(
117            " {:>width$}\n",
118            format!("{:.0}h ago -> now", time_range_hours),
119            width = width as usize - 5
120        ));
121    } else {
122        let days = time_range_hours / 24.0;
123        output.push_str(&format!(
124            " {:>width$}\n",
125            format!("{:.0}d ago -> now", days),
126            width = width as usize - 5
127        ));
128    }
129
130    output
131}
132
133/// Renders a volume chart as ASCII art using bar representation.
134///
135/// # Arguments
136///
137/// * `volume_history` - Vector of volume points over time
138/// * `width` - Chart width in characters
139/// * `height` - Chart height in characters
140///
141/// # Returns
142///
143/// Returns a string containing the ASCII chart.
144pub fn render_volume_chart(volume_history: &[VolumePoint], width: u32, height: u32) -> String {
145    if volume_history.is_empty() {
146        return "No volume data available".to_string();
147    }
148
149    let mut output = String::new();
150
151    // Calculate volume range
152    let max_volume = volume_history
153        .iter()
154        .map(|v| v.volume)
155        .fold(f64::NEG_INFINITY, f64::max);
156
157    let total_volume: f64 = volume_history.iter().map(|v| v.volume).sum();
158
159    // Format max volume for display
160    let max_vol_formatted = crate::display::format_usd(max_volume);
161    let total_vol_formatted = crate::display::format_usd(total_volume);
162
163    output.push_str(&format!(
164        "Volume (max: {}, total: {})\n",
165        max_vol_formatted, total_vol_formatted
166    ));
167
168    // Calculate time range
169    let min_time = volume_history
170        .iter()
171        .map(|v| v.timestamp)
172        .min()
173        .unwrap_or(0);
174    let max_time = volume_history
175        .iter()
176        .map(|v| v.timestamp)
177        .max()
178        .unwrap_or(0);
179
180    // Convert to textplots format
181    let points: Vec<(f32, f32)> = volume_history
182        .iter()
183        .map(|v| {
184            let x = (v.timestamp - min_time) as f32;
185            let y = v.volume as f32;
186            (x, y)
187        })
188        .collect();
189
190    let x_max = (max_time - min_time) as f32;
191    let x_min = 0.0_f32;
192
193    // Render as a bar-like chart using points
194    let chart_str = Chart::new(width, height, x_min, x_max)
195        .lineplot(&Shape::Bars(&points))
196        .to_string();
197
198    output.push_str(&chart_str);
199
200    output
201}
202
203/// Renders a holder distribution chart as ASCII bars.
204///
205/// This displays the top holders with horizontal bar representation
206/// of their percentage ownership.
207///
208/// # Arguments
209///
210/// * `holders` - Vector of token holders sorted by balance
211///
212/// # Returns
213///
214/// Returns a string containing the ASCII distribution chart.
215pub fn render_holder_distribution(holders: &[TokenHolder]) -> String {
216    if holders.is_empty() {
217        return "No holder data available".to_string();
218    }
219
220    let mut output = String::new();
221    output.push_str("Top Holders\n");
222    output.push_str(&"=".repeat(50));
223    output.push('\n');
224
225    let max_bar_width = 20;
226
227    for holder in holders.iter().take(10) {
228        // Truncate address for display (terminal only)
229        let addr_display = super::text::abbreviate(&holder.address);
230
231        // Calculate bar width based on percentage
232        let bar_width = ((holder.percentage / 100.0) * max_bar_width as f64).round() as usize;
233        let bar_width = bar_width.min(max_bar_width);
234
235        let filled = "█".repeat(bar_width);
236        let empty = "░".repeat(max_bar_width - bar_width);
237
238        output.push_str(&format!(
239            "{:>2}. {}  {:>6.2}%  {}{}\n",
240            holder.rank, addr_display, holder.percentage, filled, empty
241        ));
242    }
243
244    // Add concentration summary if we have enough holders
245    if holders.len() >= 10 {
246        let top_10_total: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
247        output.push_str(&"-".repeat(50));
248        output.push('\n');
249        output.push_str(&format!("Top 10 control: {:.1}% of supply\n", top_10_total));
250    }
251
252    output
253}
254
255/// Renders a combined analytics dashboard with price, volume, and holder charts.
256///
257/// # Arguments
258///
259/// * `price_history` - Price data points
260/// * `volume_history` - Volume data points
261/// * `holders` - Top token holders
262/// * `token_symbol` - Token symbol for the title
263/// * `chain` - Chain name for the title
264///
265/// # Returns
266///
267/// Returns a formatted string with all charts.
268pub fn render_analytics_dashboard(
269    price_history: &[PricePoint],
270    volume_history: &[VolumePoint],
271    holders: &[TokenHolder],
272    token_symbol: &str,
273    chain: &str,
274) -> String {
275    let mut output = String::new();
276
277    // Header
278    output.push_str(&format!("Token Analytics: {} on {}\n", token_symbol, chain));
279    output.push_str(&"=".repeat(60));
280    output.push_str("\n\n");
281
282    // Price chart
283    if !price_history.is_empty() {
284        output.push_str(&render_price_chart(price_history, 60, 12));
285        output.push('\n');
286    }
287
288    // Volume chart
289    if !volume_history.is_empty() {
290        output.push_str(&render_volume_chart(volume_history, 60, 8));
291        output.push('\n');
292    }
293
294    // Holder distribution
295    if !holders.is_empty() {
296        output.push_str(&render_holder_distribution(holders));
297    }
298
299    output
300}
301
302// ============================================================================
303// Unit Tests
304// ============================================================================
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_render_price_chart_empty() {
312        let result = render_price_chart(&[], 60, 10);
313        assert!(result.contains("No price data"));
314    }
315
316    #[test]
317    fn test_render_price_chart_with_data() {
318        let history = vec![
319            PricePoint {
320                timestamp: 0,
321                price: 100.0,
322            },
323            PricePoint {
324                timestamp: 3600,
325                price: 105.0,
326            },
327            PricePoint {
328                timestamp: 7200,
329                price: 102.0,
330            },
331        ];
332
333        let result = render_price_chart(&history, 60, 10);
334        assert!(!result.is_empty());
335        assert!(result.contains("Price"));
336    }
337
338    #[test]
339    fn test_render_volume_chart_empty() {
340        let result = render_volume_chart(&[], 60, 10);
341        assert!(result.contains("No volume data"));
342    }
343
344    #[test]
345    fn test_render_volume_chart_with_data() {
346        let history = vec![
347            VolumePoint {
348                timestamp: 0,
349                volume: 1000000.0,
350            },
351            VolumePoint {
352                timestamp: 3600,
353                volume: 1500000.0,
354            },
355        ];
356
357        let result = render_volume_chart(&history, 60, 10);
358        assert!(!result.is_empty());
359        assert!(result.contains("Volume"));
360    }
361
362    #[test]
363    fn test_render_holder_distribution_empty() {
364        let result = render_holder_distribution(&[]);
365        assert!(result.contains("No holder data"));
366    }
367
368    #[test]
369    fn test_render_holder_distribution_with_data() {
370        let holders = vec![
371            TokenHolder {
372                address: "0x1234567890123456789012345678901234567890".to_string(),
373                balance: "1000000".to_string(),
374                formatted_balance: "1M".to_string(),
375                percentage: 25.0,
376                rank: 1,
377            },
378            TokenHolder {
379                address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd".to_string(),
380                balance: "500000".to_string(),
381                formatted_balance: "500K".to_string(),
382                percentage: 12.5,
383                rank: 2,
384            },
385        ];
386
387        let result = render_holder_distribution(&holders);
388        assert!(result.contains("Top Holders"));
389        assert!(result.contains("25.00%"));
390        assert!(result.contains("12.50%"));
391        assert!(result.contains("█")); // Has bar characters
392    }
393
394    #[test]
395    fn test_format_large_number() {
396        assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
397        assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
398        assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
399        assert_eq!(crate::display::format_large_number(500.0), "500.00");
400    }
401
402    #[test]
403    fn test_chart_config_default() {
404        let config = ChartConfig::default();
405        assert_eq!(config.width, 60);
406        assert_eq!(config.height, 15);
407        assert!(config.show_labels);
408    }
409
410    #[test]
411    fn test_render_analytics_dashboard() {
412        let prices = vec![PricePoint {
413            timestamp: 0,
414            price: 1.0,
415        }];
416        let volumes = vec![VolumePoint {
417            timestamp: 0,
418            volume: 1000.0,
419        }];
420        let holders = vec![TokenHolder {
421            address: "0x1234567890123456789012345678901234567890".to_string(),
422            balance: "1000".to_string(),
423            formatted_balance: "1K".to_string(),
424            percentage: 50.0,
425            rank: 1,
426        }];
427
428        let result = render_analytics_dashboard(&prices, &volumes, &holders, "TEST", "ethereum");
429        assert!(result.contains("Token Analytics: TEST on ethereum"));
430    }
431
432    #[test]
433    fn test_price_chart_multiday_range() {
434        // Time range > 24h to trigger the "Xd ago" branch
435        let prices: Vec<PricePoint> = (0..50)
436            .map(|i| PricePoint {
437                timestamp: i * 7200, // every 2 hours, spanning ~4 days
438                price: 1.0 + (i as f64) * 0.01,
439            })
440            .collect();
441        let chart = render_price_chart(&prices, 60, 15);
442        assert!(chart.contains("d ago -> now"));
443    }
444
445    #[test]
446    fn test_holder_distribution_with_10_holders() {
447        // >= 10 holders triggers concentration summary
448        let holders: Vec<TokenHolder> = (1..=12)
449            .map(|i| TokenHolder {
450                address: format!("0x{:040}", i),
451                balance: format!("{}", 1000 - i * 50),
452                formatted_balance: format!("{}K", (1000 - i * 50) / 1000),
453                percentage: 10.0 - (i as f64) * 0.5,
454                rank: i as u32,
455            })
456            .collect();
457        let chart = render_holder_distribution(&holders);
458        assert!(chart.contains("Top 10 control:"));
459        assert!(chart.contains("% of supply"));
460    }
461}