opsis 0.1.0

Config-driven framework for blazingly fast visualizations.
Documentation

opsis

A Vega-style plotting framework for Rust. Describe a chart in TOML, then render it as a native desktop window (egui) or a terminal UI (ratatui).

Python users can drive the same renderer via PyO3 bindings — no Rust knowledge required.


Table of contents

  1. Quick start (Rust)
  2. Quick start (Python)
  3. The spec format
  4. Chart types
  5. Loading data
  6. Embedding in an existing app
  7. Adding a new chart type
  8. Feature flags
  9. Building & testing
  10. Project layout

1. Quick start (Rust)

Add opsis to your Cargo.toml:

[dependencies]
opsis = { path = "path/to/opsis" }          # or use git/crates.io once published

Write a TOML spec (or start from examples/configs/bar.toml):

[chart]
type  = "bar"
title = "Quarterly revenue"

[data]
values = [
    { quarter = "Q1", revenue = 120 },
    { quarter = "Q2", revenue = 145 },
    { quarter = "Q3", revenue = 132 },
    { quarter = "Q4", revenue = 178 },
]

[encoding.x]
field = "quarter"
type  = "categorical"

[encoding.y]
field = "revenue"
type  = "quantitative"

[style]
color = "#4C78A8"

Then in Rust:

fn main() -> Result<(), opsis::OpsisError> {
    // Open a native egui window
    opsis::show_path("chart.toml")?;

    // —— or —— render in the current terminal (q / Esc / Ctrl-C to quit)
    opsis::show_terminal_path("chart.toml")?;

    Ok(())
}

You can also parse and use the spec programmatically:

use opsis::{ChartSpec, Dataset};

let spec = ChartSpec::from_toml_path("chart.toml")?;
spec.validate()?;                       // returns Err with a helpful message on bad config
let data: Dataset = spec.load_data()?; // loads inline values or reads the file
println!("{} records", data.len());

Try the included demo binaries:

# native window
cargo run -p opsis --example egui_demo -- examples/configs/bar.toml

# terminal
cargo run -p opsis --example ratatui_demo -- examples/configs/scatter.toml

2. Quick start (Python)

Install

Build and install the Python extension with maturin:

pip install maturin
maturin develop -m opsis-py/Cargo.toml --release

This compiles the Rust code and installs opsis as a regular Python package in the current environment (virtual env or conda env).

Use

import opsis

# From a TOML file ─────────────────────────────────────────────────────────
opsis.show("examples/configs/bar.toml")          # egui window (blocks)
opsis.show_terminal("examples/configs/bar.toml") # terminal TUI (q to quit)

# From a TOML string ────────────────────────────────────────────────────────
toml = """
[chart]
type = "line"
title = "sin wave"

[data]
values = [
    {x = 0, y = 0.0}, {x = 1, y = 0.84}, {x = 2, y = 0.91},
    {x = 3, y = 0.14}, {x = 4, y = -0.76}, {x = 5, y = -0.96},
]

[encoding.x]
field = "x"

[encoding.y]
field = "y"
title = "sin(x)"
"""
opsis.show_str(toml)
opsis.show_terminal_str(toml)

# From a Python dict (no TOML needed) ───────────────────────────────────────
import math
spec = {
    "chart": {"type": "scatter", "title": "Parabola"},
    "data": {
        "values": [{"x": i, "y": i**2} for i in range(-5, 6)]
    },
    "encoding": {
        "x": {"field": "x", "title": "x"},
        "y": {"field": "y", "title": ""},
    },
    "style": {"color": "#E45756"},
}
opsis.show_dict(spec)          # egui window
opsis.show_terminal_dict(spec) # terminal

# Validate without rendering ─────────────────────────────────────────────────
try:
    canonical = opsis.validate_path("chart.toml")
    print(canonical)  # prints the re-serialised TOML on success
except ValueError as e:
    print("bad spec:", e)

3. The spec format

A spec is a single TOML file with four top-level sections.

3.1 [chart]

[chart]
type   = "bar"       # required — see §4 for all values
title  = "My chart"  # optional window/widget title
width  = 900         # egui only — window width in logical pixels
height = 600         # egui only — window height in logical pixels

3.2 [data]

Two forms — pick one.

Inline values (great for small datasets or demos):

[data]
values = [
    { name = "Alice", score = 92 },
    { name = "Bob",   score = 78 },
]

Records are free-form TOML objects. Any key is a valid field name. Numbers are read as f64; everything else as a string.

External file (CSV or JSON):

[data]
source = "results.csv"    # path relative to the working directory
# format = "csv"          # optional; inferred from extension (.csv/.tsv → csv, .json → json)

A CSV file must have a header row. Numeric cells are automatically promoted to numbers; empty cells become null.

A JSON file must be a JSON array of objects:

[{"name": "Alice", "score": 92}, {"name": "Bob", "score": 78}]

3.3 [encoding]

Encodings map dataset fields to visual channels.

[encoding.x]
field     = "quarter"       # column name in your dataset
type      = "categorical"   # quantitative | categorical | temporal | ordinal
title     = "Quarter"       # axis/legend label (defaults to field name)
aggregate = "sum"           # sum | mean | count | min | max
                            # when multiple rows share the same x value,
                            # the y values are collapsed using this function

[encoding.y]
field = "revenue"
type  = "quantitative"
# aggregate = "mean"        # optional

[encoding.value]   # required for: pie, histogram
field = "amount"

[encoding.category]  # pie slice labels (falls back to encoding.x)
field = "product"

[encoding.color]   # heatmap intensity (alias for value)
field = "density"

Which channels does each chart type use?

Chart Required Optional
bar x, y
line x, y
area x, y
scatter x, y
histogram value (or x)
pie value category (or x)
heatmap x, y, value (or color)
box_plot x (categorical), y (numeric)

3.4 [style]

All fields are optional.

[style]
color      = "#4C78A8"             # primary hex colour (short #RGB or full #RRGGBB)
palette    = ["#4C78A8", "#F58518", "#54A24B"]
                                   # per-slice/per-bar colour cycle
background = "#ffffff"             # reserved (not yet rendered)
bins       = 10                    # histogram: number of bins (default: Sturges' rule)
grid       = true                  # show grid lines (default: true)
legend     = true                  # show legend (default: true)

4. Chart types

4.1 bar

Groups records by the x field (categorical) and aggregates y (default: sum).

[chart]
type = "bar"
title = "Sales by region"

[data]
values = [
    { region = "North", sales = 420 },
    { region = "South", sales = 380 },
    { region = "East",  sales = 510 },
    { region = "West",  sales = 460 },
]

[encoding.x]
field = "region"
type  = "categorical"

[encoding.y]
field     = "sales"
aggregate = "sum"

If you have repeated categories (e.g. daily data grouped by month), set aggregate on [encoding.y]:

[encoding.y]
field     = "revenue"
aggregate = "mean"   # show average per category

4.2 line

Plots (x, y) pairs connected by a line. x is typically numeric.

[chart]
type = "line"
title = "Temperature over time"

[data]
source = "temperature.csv"

[encoding.x]
field = "day"

[encoding.y]
field = "temp_c"
title = "°C"

4.3 area

Same as line but fills the area below the curve.

[chart]
type = "area"
title = "Cumulative signups"

[encoding.x]
field = "week"

[encoding.y]
field = "signups"

4.4 scatter

Plots each record as an independent point.

[chart]
type = "scatter"
title = "Height vs weight"

[encoding.x]
field = "height_cm"
title = "Height (cm)"

[encoding.y]
field = "weight_kg"
title = "Weight (kg)"

4.5 histogram

Bins a single numeric field into ranges and counts how many records fall in each. Use [encoding.value] (or [encoding.x]) to pick the field; set style.bins to control the number of bins (default uses Sturges' rule).

[chart]
type = "histogram"
title = "Response time distribution"

[data]
source = "timings.csv"

[encoding.value]
field = "response_ms"

[style]
bins  = 20
color = "#72B7B2"

4.6 pie

Draws one slice per category, sized by value. Use [encoding.category] for labels and [encoding.value] for the numeric measure.

[chart]
type = "pie"
title = "OS market share"

[data]
values = [
    { os = "Windows", share = 72 },
    { os = "macOS",   share = 15 },
    { os = "Linux",   share = 4  },
    { os = "Other",   share = 9  },
]

[encoding.category]
field = "os"

[encoding.value]
field = "share"

[style]
palette = ["#4C78A8", "#F58518", "#54A24B", "#E45756"]
legend  = true

ratatui note: pie is rendered as a horizontal bar chart of percentages because terminals can't paint circular arcs. egui draws real arc-based slices.

4.7 heatmap

A grid where colour intensity encodes the value. x and y are categorical; value (or color) holds the numeric intensity.

[chart]
type = "heatmap"
title = "Support tickets by day and hour"

[data]
source = "tickets.csv"  # columns: weekday, hour, count

[encoding.x]
field = "hour"

[encoding.y]
field = "weekday"

[encoding.value]
field = "count"

[style]
color = "#B279A2"   # high-intensity end of the colour ramp

4.8 box_plot

Calculates min / Q1 / median / Q3 / max per category and draws a box-and-whisker plot.

[chart]
type = "box_plot"
title = "Latency by region"

[data]
source = "latency.csv"  # columns: region, latency_ms

[encoding.x]
field = "region"
type  = "categorical"

[encoding.y]
field = "latency_ms"
type  = "quantitative"

5. Loading data

opsis exposes the Dataset type for use in Rust code:

use opsis::Dataset;

// From a CSV file
let data = Dataset::from_csv_path("data.csv")?;

// From a JSON string
let data = Dataset::from_json_str(r#"[{"x": 1, "y": 2}]"#)?;

// Extract columns
let values: Vec<f64>   = data.column_f64("price")?;
let labels: Vec<String> = data.column_str("category")?;

println!("fields: {:?}", data.fields()); // all column names
println!("rows:   {}", data.len());

You can build records manually:

use opsis::{Dataset, Record, Value};

let mut record = Record::new();
record.insert("name".into(), Value::from("Alice"));
record.insert("score".into(), Value::from(92.0_f64));

let data = Dataset::new(vec![record]);

6. Embedding in an existing app

The show* functions block the calling thread. If you want to embed a chart inside your own egui or ratatui app, call the backend functions directly:

egui

use opsis::{ChartSpec, Dataset};
use opsis::render::egui_backend;

// Inside your eframe::App::update() method:
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
    egui::CentralPanel::default().show(ctx, |ui| {
        if let Err(e) = egui_backend::draw(ui, &self.spec, &self.data) {
            ui.colored_label(egui::Color32::RED, e.to_string());
        }
    });
}

ratatui

use opsis::render::ratatui_backend;

// Inside your ratatui draw closure:
terminal.draw(|frame| {
    let area = frame.size();
    ratatui_backend::render(frame, area, &spec, &data).unwrap();
})?;

7. Adding a new chart type

Three small edits are needed:

1. Add the variant in opsis/src/config.rs:

pub enum ChartType {
    // existing...
    Candlestick, // new
}

2. Add aggregation logic in opsis/src/render/mod.rs (optional — reuse extract_xy or aggregate_categorical if the shape fits, otherwise write a new builder function following the existing patterns).

3. Add a match arm in both backends:

// opsis/src/render/egui_backend.rs  — inside draw()
ChartType::Candlestick => draw_candlestick(ui, spec, data),

// opsis/src/render/ratatui_backend.rs  — inside render()
ChartType::Candlestick => render_candlestick(frame, plot_area, spec, data),

8. Feature flags

[dependencies]
# Default: both backends enabled.
opsis = { path = "..." }

# Only egui (no terminal dependency).
opsis = { path = "...", default-features = false, features = ["egui-backend"] }

# Only ratatui (no GUI dependency — good for servers / CI).
opsis = { path = "...", default-features = false, features = ["ratatui-backend"] }

# Headless: just parsing + data loading. No rendering at all.
opsis = { path = "...", default-features = false }
Feature Adds Use when
egui-backend egui, eframe, egui_plot building a desktop app
ratatui-backend ratatui, crossterm building a CLI or server tool
(neither) only need TOML parsing and data aggregation

9. Building & testing

# Check everything
cargo check --workspace --all-features

# Build
cargo build --workspace

# Run unit tests
cargo test -p opsis

# Run the egui demo  (opens a window)
cargo run -p opsis --example egui_demo -- examples/configs/bar.toml
cargo run -p opsis --example egui_demo -- examples/configs/pie.toml
cargo run -p opsis --example egui_demo -- examples/configs/heatmap.toml

# Run the ratatui demo  (press q to quit)
cargo run -p opsis --example ratatui_demo -- examples/configs/line.toml
cargo run -p opsis --example ratatui_demo -- examples/configs/boxplot.toml

# Python bindings
pip install maturin
maturin develop -m opsis-py/Cargo.toml --release
python examples/python/demo.py
python examples/python/demo.py --terminal
python examples/python/demo.py --inline

10. Project layout

.
├── Cargo.toml                  workspace root
├── README.md
├── opsis/                      core library
│   ├── Cargo.toml
│   ├── examples/
│   │   ├── egui_demo.rs        cargo run --example egui_demo
│   │   └── ratatui_demo.rs     cargo run --example ratatui_demo
│   └── src/
│       ├── lib.rs              public API  (show / show_terminal / *_path / *_str)
│       ├── config.rs           spec types  (ChartSpec, Encoding, Style, …)
│       ├── data.rs             Dataset, Value, CSV/JSON loaders
│       ├── error.rs            OpsisError
│       └── render/
│           ├── mod.rs          shared aggregation helpers (bar_data, pie_data, …)
│           ├── egui_backend.rs egui_plot + custom pie arc + custom heatmap grid
│           └── ratatui_backend.rs ratatui BarChart/Chart + ASCII pie/heatmap/box
├── opsis-py/                   Python extension (PyO3 / maturin)
│   ├── Cargo.toml
│   ├── pyproject.toml          maturin build config
│   └── src/lib.rs              show / show_dict / show_terminal* / validate
└── examples/
    ├── configs/                ready-to-use TOML specs
    │   ├── bar.toml
    │   ├── line.toml
    │   ├── area.toml
    │   ├── scatter.toml
    │   ├── histogram.toml
    │   ├── pie.toml
    │   ├── heatmap.toml
    │   └── boxplot.toml
    └── python/
        └── demo.py             Python demo (show / show_terminal / show_dict)

Why "opsis"?

Greek ὄψις — "appearance, sight". Short, no name clashes, easy to type.


License

Dual-licensed under MIT or Apache-2.0, at your option.