opsis 0.1.0

Config-driven framework for blazingly fast visualizations.
Documentation
//! ratatui rendering of an opsis [`ChartSpec`].
//!
//! Two entry points:
//! * [`run_terminal`] — takes over the terminal, draws the chart, and
//!   waits for `q`/`Esc`/`Ctrl-C` to quit.
//! * [`render`] — draws into a given `Frame`, for embedding inside an
//!   existing ratatui app.

use std::io;

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    symbols,
    text::{Line, Span},
    widgets::{
        Axis, BarChart as TuiBar, Block, Borders, Chart, Dataset as TuiDs, GraphType, Paragraph,
    },
    Frame, Terminal,
};

use super::{
    bar_data, boxplot_stats, extract_xy, heatmap_cells, histogram_data, palette_for, pie_data,
    primary_color,
};
use crate::config::{ChartSpec, ChartType};
use crate::data::Dataset;
use crate::error::Result;

fn rgb(c: (u8, u8, u8)) -> Color {
    Color::Rgb(c.0, c.1, c.2)
}

/// Take over the terminal, draw, wait for quit.
pub fn run_terminal(spec: ChartSpec) -> Result<()> {
    let data = spec.load_data()?;
    spec.validate()?;

    enable_raw_mode().map_err(io_to_opsis)?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture).map_err(io_to_opsis)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend).map_err(io_to_opsis)?;

    let res = (|| -> Result<()> {
        loop {
            terminal
                .draw(|f| {
                    let _ = render(f, f.size(), &spec, &data);
                })
                .map_err(io_to_opsis)?;
            if event::poll(std::time::Duration::from_millis(200)).map_err(io_to_opsis)? {
                if let Event::Key(k) = event::read().map_err(io_to_opsis)? {
                    let ctrl_c = k.modifiers.contains(KeyModifiers::CONTROL)
                        && matches!(k.code, KeyCode::Char('c'));
                    if ctrl_c
                        || matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
                    {
                        break;
                    }
                }
            }
        }
        Ok(())
    })();

    disable_raw_mode().map_err(io_to_opsis)?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)
        .map_err(io_to_opsis)?;
    terminal.show_cursor().map_err(io_to_opsis)?;
    res
}

fn io_to_opsis(e: io::Error) -> crate::error::OpsisError {
    crate::error::OpsisError::Io(e)
}

/// Render a chart into the given area of an existing frame.
pub fn render(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(vec![
            Constraint::Length(if spec.chart.title.is_some() { 2 } else { 0 }),
            Constraint::Min(3),
        ])
        .split(area);

    if let Some(t) = &spec.chart.title {
        let title = Paragraph::new(Line::from(Span::styled(
            t.clone(),
            Style::default().add_modifier(Modifier::BOLD),
        )));
        frame.render_widget(title, chunks[0]);
    }

    let plot_area = chunks[1];
    match spec.chart.r#type {
        ChartType::Bar => render_bar(frame, plot_area, spec, data),
        ChartType::Line => render_line(frame, plot_area, spec, data, false),
        ChartType::Area => render_line(frame, plot_area, spec, data, true),
        ChartType::Scatter => render_line(frame, plot_area, spec, data, false), // dots
        ChartType::Histogram => render_histogram(frame, plot_area, spec, data),
        ChartType::Pie => render_pie(frame, plot_area, spec, data),
        ChartType::Heatmap => render_heatmap(frame, plot_area, spec, data),
        ChartType::BoxPlot => render_boxplot(frame, plot_area, spec, data),
    }
}

fn axis_label(spec: &ChartSpec, which: &str) -> String {
    let ch = match which {
        "x" => spec.encoding.x.as_ref(),
        "y" => spec.encoding.y.as_ref(),
        _ => None,
    };
    ch.and_then(|c| c.title.clone().or(Some(c.field.clone()))).unwrap_or_default()
}

fn render_bar(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let bars = bar_data(spec, data)?;
    if bars.is_empty() { return Ok(()); }
    let max = bars.iter().map(|b| b.value).fold(0.0_f64, f64::max);
    let scale = if max > u64::MAX as f64 { (u64::MAX as f64) / max } else { 1.0 };
    let labels: Vec<(&str, u64)> = bars
        .iter()
        .map(|b| (b.label.as_str(), (b.value * scale).round().max(0.0) as u64))
        .collect();

    let widget = TuiBar::default()
        .block(Block::default().borders(Borders::ALL).title(spec.title().to_string()))
        .data(&labels)
        .bar_width(((area.width as usize).saturating_sub(4) / labels.len().max(1)).max(3) as u16)
        .bar_style(Style::default().fg(rgb(primary_color(spec))))
        .value_style(Style::default().fg(Color::White));
    frame.render_widget(widget, area);
    Ok(())
}

fn render_line(
    frame: &mut Frame<'_>,
    area: Rect,
    spec: &ChartSpec,
    data: &Dataset,
    _fill: bool,
) -> Result<()> {
    let pts = extract_xy(spec, data)?;
    if pts.is_empty() { return Ok(()); }
    let xs: Vec<(f64, f64)> = pts.iter().map(|p| (p.x, p.y)).collect();

    let (min_x, max_x) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, p| {
        (a.0.min(p.x), a.1.max(p.x))
    });
    let (min_y, max_y) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |a, p| {
        (a.0.min(p.y), a.1.max(p.y))
    });
    let pad_y = ((max_y - min_y).abs() * 0.05).max(1.0);
    let pad_x = ((max_x - min_x).abs() * 0.02).max(0.5);

    let graph_type = if matches!(spec.chart.r#type, ChartType::Scatter) {
        GraphType::Scatter
    } else {
        GraphType::Line
    };

    let ds = TuiDs::default()
        .name(spec.encoding.y.as_ref().map(|c| c.field.clone()).unwrap_or_default())
        .marker(if matches!(graph_type, GraphType::Scatter) {
            symbols::Marker::Dot
        } else {
            symbols::Marker::Braille
        })
        .style(Style::default().fg(rgb(primary_color(spec))))
        .graph_type(graph_type)
        .data(&xs);

    let chart = Chart::new(vec![ds])
        .block(Block::default().borders(Borders::ALL).title(spec.title().to_string()))
        .x_axis(
            Axis::default()
                .title(axis_label(spec, "x"))
                .style(Style::default().fg(Color::Gray))
                .bounds([min_x - pad_x, max_x + pad_x])
                .labels(vec![
                    Span::raw(format!("{:.2}", min_x)),
                    Span::raw(format!("{:.2}", (min_x + max_x) / 2.0)),
                    Span::raw(format!("{:.2}", max_x)),
                ]),
        )
        .y_axis(
            Axis::default()
                .title(axis_label(spec, "y"))
                .style(Style::default().fg(Color::Gray))
                .bounds([min_y - pad_y, max_y + pad_y])
                .labels(vec![
                    Span::raw(format!("{:.2}", min_y)),
                    Span::raw(format!("{:.2}", (min_y + max_y) / 2.0)),
                    Span::raw(format!("{:.2}", max_y)),
                ]),
        );
    frame.render_widget(chart, area);
    Ok(())
}

fn render_histogram(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let bins = histogram_data(spec, data)?;
    if bins.is_empty() { return Ok(()); }
    let labels: Vec<(&str, u64)> = bins
        .iter()
        .map(|b| (b.label.as_str(), b.value.max(0.0) as u64))
        .collect();
    let widget = TuiBar::default()
        .block(Block::default().borders(Borders::ALL).title(spec.title().to_string()))
        .data(&labels)
        .bar_width(((area.width as usize).saturating_sub(4) / labels.len().max(1)).max(2) as u16)
        .bar_style(Style::default().fg(rgb(primary_color(spec))));
    frame.render_widget(widget, area);
    Ok(())
}

/// Pie isn't a ratatui widget — render an ASCII legend with percentages.
fn render_pie(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let slices = pie_data(spec, data)?;
    let total: f64 = slices.iter().map(|s| s.value).sum();
    let palette = palette_for(spec);

    let mut lines: Vec<Line> = Vec::new();
    for (i, s) in slices.iter().enumerate() {
        let pct = if total > 0.0 { 100.0 * s.value / total } else { 0.0 };
        let bar_len = ((area.width as f64 - 32.0).max(4.0) * (pct / 100.0)) as usize;
        let bar: String = "".repeat(bar_len);
        let color = palette.get(i % palette.len()).copied().unwrap_or((76, 120, 168));
        lines.push(Line::from(vec![
            Span::raw(format!("{:>14} ", truncate(&s.label, 14))),
            Span::styled(bar, Style::default().fg(rgb(color))),
            Span::raw(format!(" {:>5.1}% ({:.2})", pct, s.value)),
        ]));
    }
    let p = Paragraph::new(lines)
        .block(Block::default().borders(Borders::ALL).title(spec.title().to_string()));
    frame.render_widget(p, area);
    Ok(())
}

fn render_heatmap(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let (xs, ys, cells) = heatmap_cells(spec, data)?;
    if cells.is_empty() { return Ok(()); }
    let min = cells.iter().map(|c| c.value).fold(f64::INFINITY, f64::min);
    let max = cells.iter().map(|c| c.value).fold(f64::NEG_INFINITY, f64::max);
    let span = (max - min).max(f64::EPSILON);

    // Build a grid of strings to render.
    let glyphs = ['·', '', '', '', ''];
    let mut grid: Vec<Vec<char>> = vec![vec![' '; xs.len()]; ys.len()];
    for c in &cells {
        let t = ((c.value - min) / span).clamp(0.0, 1.0);
        let idx = ((t * (glyphs.len() - 1) as f64).round() as usize).min(glyphs.len() - 1);
        grid[c.y_idx][c.x_idx] = glyphs[idx];
    }

    let label_w = ys.iter().map(|s| s.len()).max().unwrap_or(0).min(12) as u16;
    let mut lines: Vec<Line> = Vec::new();
    for (yi, row) in grid.iter().enumerate() {
        let label = truncate(&ys[yi], label_w as usize);
        let cells_str: String = row.iter().collect();
        lines.push(Line::from(vec![
            Span::styled(format!("{:>w$} ", label, w = label_w as usize), Style::default().fg(Color::Gray)),
            Span::styled(cells_str, Style::default().fg(rgb(primary_color(spec)))),
        ]));
    }
    // x labels: vertical first chars only (terminal-friendly).
    let header: String = std::iter::repeat(' ')
        .take(label_w as usize + 1)
        .chain(xs.iter().map(|s| s.chars().next().unwrap_or(' ')))
        .collect();
    lines.push(Line::from(Span::styled(header, Style::default().fg(Color::DarkGray))));

    let p = Paragraph::new(lines).block(
        Block::default()
            .borders(Borders::ALL)
            .title(format!("{} (min {:.2}, max {:.2})", spec.title(), min, max)),
    );
    frame.render_widget(p, area);
    Ok(())
}

fn render_boxplot(frame: &mut Frame<'_>, area: Rect, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let stats = boxplot_stats(spec, data)?;
    if stats.is_empty() { return Ok(()); }
    let global_min = stats.iter().map(|s| s.min).fold(f64::INFINITY, f64::min);
    let global_max = stats.iter().map(|s| s.max).fold(f64::NEG_INFINITY, f64::max);
    let span = (global_max - global_min).max(f64::EPSILON);

    let label_w = stats.iter().map(|s| s.label.len()).max().unwrap_or(0).min(14);
    let bar_w = (area.width as usize).saturating_sub(label_w + 6).max(10);

    let scale = |v: f64| -> usize {
        ((v - global_min) / span * (bar_w as f64 - 1.0)).round() as usize
    };
    let color = rgb(primary_color(spec));
    let mut lines: Vec<Line> = Vec::new();
    for s in &stats {
        let mut row: Vec<char> = vec![' '; bar_w];
        let lo = scale(s.min);
        let q1 = scale(s.q1);
        let med = scale(s.median);
        let q3 = scale(s.q3);
        let hi = scale(s.max);
        for i in lo..=hi { if i < bar_w { row[i] = ''; } }
        for i in q1..=q3 { if i < bar_w { row[i] = ''; } }
        if med < bar_w { row[med] = ''; }
        let s_str: String = row.into_iter().collect();
        lines.push(Line::from(vec![
            Span::styled(format!("{:>w$} ", truncate(&s.label, label_w), w = label_w), Style::default().fg(Color::Gray)),
            Span::styled(s_str, Style::default().fg(color)),
            Span::raw(format!(" [{:.2}, {:.2}]", s.min, s.max)),
        ]));
    }
    let p = Paragraph::new(lines).block(
        Block::default()
            .borders(Borders::ALL)
            .title(spec.title().to_string()),
    );
    frame.render_widget(p, area);
    Ok(())
}

fn truncate(s: &str, n: usize) -> String {
    if s.chars().count() <= n { s.to_string() } else {
        let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
        out.push('');
        out
    }
}