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