# opsis
A Vega-style plotting framework for Rust. Describe a chart in TOML, then
render it as a native desktop window ([egui](https://github.com/emilk/egui))
or a terminal UI ([ratatui](https://github.com/ratatui-org/ratatui)).
Python users can drive the same renderer via PyO3 bindings — no Rust
knowledge required.
---
## Table of contents
1. [Quick start (Rust)](#1-quick-start-rust)
2. [Quick start (Python)](#2-quick-start-python)
3. [The spec format](#3-the-spec-format)
- [chart](#31-chart)
- [data](#32-data)
- [encoding](#33-encoding)
- [style](#34-style)
4. [Chart types](#4-chart-types)
- [bar](#41-bar)
- [line](#42-line)
- [area](#43-area)
- [scatter](#44-scatter)
- [histogram](#45-histogram)
- [pie](#46-pie)
- [heatmap](#47-heatmap)
- [box\_plot](#48-box_plot)
5. [Loading data](#5-loading-data)
6. [Embedding in an existing app](#6-embedding-in-an-existing-app)
7. [Adding a new chart type](#7-adding-a-new-chart-type)
8. [Feature flags](#8-feature-flags)
9. [Building & testing](#9-building--testing)
10. [Project layout](#10-project-layout)
---
## 1. Quick start (Rust)
Add opsis to your `Cargo.toml`:
```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`):
```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:
```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:
```rust
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:
```bash
# 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](https://github.com/PyO3/maturin):
```bash
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
```python
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": "x²"},
},
"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]`
```toml
[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):
```toml
[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):
```toml
[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:
```json
[{"name": "Alice", "score": 92}, {"name": "Bob", "score": 78}]
```
### 3.3 `[encoding]`
Encodings map dataset fields to visual channels.
```toml
[encoding.x]
field = "quarter" # column name in your dataset
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?**
| `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.
```toml
[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).
```toml
[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]`:
```toml
[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.
```toml
[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.
```toml
[chart]
type = "area"
title = "Cumulative signups"
[encoding.x]
field = "week"
[encoding.y]
field = "signups"
```
### 4.4 scatter
Plots each record as an independent point.
```toml
[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).
```toml
[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.
```toml
[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.
```toml
[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.
```toml
[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:
```rust
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:
```rust
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
```rust
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
```rust
use opsis::render::ratatui_backend;
// Inside your ratatui draw closure:
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`:
```rust
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:
```rust
// 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
```toml
[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 }
```
| `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
```bash
# 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.