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_large_number(max_volume);
161    let total_vol_formatted = crate::display::format_large_number(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 = truncate_address(&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/// Truncates an address for terminal display.
303fn truncate_address(address: &str) -> String {
304    if address.len() <= 13 {
305        address.to_string()
306    } else {
307        format!("{}...{}", &address[..6], &address[address.len() - 4..])
308    }
309}
310
311// ============================================================================
312// Unit Tests
313// ============================================================================
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_render_price_chart_empty() {
321        let result = render_price_chart(&[], 60, 10);
322        assert!(result.contains("No price data"));
323    }
324
325    #[test]
326    fn test_render_price_chart_with_data() {
327        let history = vec![
328            PricePoint {
329                timestamp: 0,
330                price: 100.0,
331            },
332            PricePoint {
333                timestamp: 3600,
334                price: 105.0,
335            },
336            PricePoint {
337                timestamp: 7200,
338                price: 102.0,
339            },
340        ];
341
342        let result = render_price_chart(&history, 60, 10);
343        assert!(!result.is_empty());
344        assert!(result.contains("Price"));
345    }
346
347    #[test]
348    fn test_render_volume_chart_empty() {
349        let result = render_volume_chart(&[], 60, 10);
350        assert!(result.contains("No volume data"));
351    }
352
353    #[test]
354    fn test_render_volume_chart_with_data() {
355        let history = vec![
356            VolumePoint {
357                timestamp: 0,
358                volume: 1000000.0,
359            },
360            VolumePoint {
361                timestamp: 3600,
362                volume: 1500000.0,
363            },
364        ];
365
366        let result = render_volume_chart(&history, 60, 10);
367        assert!(!result.is_empty());
368        assert!(result.contains("Volume"));
369    }
370
371    #[test]
372    fn test_render_holder_distribution_empty() {
373        let result = render_holder_distribution(&[]);
374        assert!(result.contains("No holder data"));
375    }
376
377    #[test]
378    fn test_render_holder_distribution_with_data() {
379        let holders = vec![
380            TokenHolder {
381                address: "0x1234567890123456789012345678901234567890".to_string(),
382                balance: "1000000".to_string(),
383                formatted_balance: "1M".to_string(),
384                percentage: 25.0,
385                rank: 1,
386            },
387            TokenHolder {
388                address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd".to_string(),
389                balance: "500000".to_string(),
390                formatted_balance: "500K".to_string(),
391                percentage: 12.5,
392                rank: 2,
393            },
394        ];
395
396        let result = render_holder_distribution(&holders);
397        assert!(result.contains("Top Holders"));
398        assert!(result.contains("25.00%"));
399        assert!(result.contains("12.50%"));
400        assert!(result.contains("█")); // Has bar characters
401    }
402
403    #[test]
404    fn test_truncate_address() {
405        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
406        let truncated = truncate_address(addr);
407        assert_eq!(truncated, "0x742d...b3c2");
408
409        // Short addresses stay the same
410        let short = "0x123";
411        assert_eq!(truncate_address(short), "0x123");
412    }
413
414    #[test]
415    fn test_format_large_number() {
416        assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
417        assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
418        assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
419        assert_eq!(crate::display::format_large_number(500.0), "500.00");
420    }
421
422    #[test]
423    fn test_chart_config_default() {
424        let config = ChartConfig::default();
425        assert_eq!(config.width, 60);
426        assert_eq!(config.height, 15);
427        assert!(config.show_labels);
428    }
429
430    #[test]
431    fn test_render_analytics_dashboard() {
432        let prices = vec![PricePoint {
433            timestamp: 0,
434            price: 1.0,
435        }];
436        let volumes = vec![VolumePoint {
437            timestamp: 0,
438            volume: 1000.0,
439        }];
440        let holders = vec![TokenHolder {
441            address: "0x1234567890123456789012345678901234567890".to_string(),
442            balance: "1000".to_string(),
443            formatted_balance: "1K".to_string(),
444            percentage: 50.0,
445            rank: 1,
446        }];
447
448        let result = render_analytics_dashboard(&prices, &volumes, &holders, "TEST", "ethereum");
449        assert!(result.contains("Token Analytics: TEST on ethereum"));
450    }
451
452    #[test]
453    fn test_price_chart_multiday_range() {
454        // Time range > 24h to trigger the "Xd ago" branch
455        let prices: Vec<PricePoint> = (0..50)
456            .map(|i| PricePoint {
457                timestamp: i * 7200, // every 2 hours, spanning ~4 days
458                price: 1.0 + (i as f64) * 0.01,
459            })
460            .collect();
461        let chart = render_price_chart(&prices, 60, 15);
462        assert!(chart.contains("d ago -> now"));
463    }
464
465    #[test]
466    fn test_holder_distribution_with_10_holders() {
467        // >= 10 holders triggers concentration summary
468        let holders: Vec<TokenHolder> = (1..=12)
469            .map(|i| TokenHolder {
470                address: format!("0x{:040}", i),
471                balance: format!("{}", 1000 - i * 50),
472                formatted_balance: format!("{}K", (1000 - i * 50) / 1000),
473                percentage: 10.0 - (i as f64) * 0.5,
474                rank: i as u32,
475            })
476            .collect();
477        let chart = render_holder_distribution(&holders);
478        assert!(chart.contains("Top 10 control:"));
479        assert!(chart.contains("% of supply"));
480    }
481}