# Plotlars Multi-Backend Architecture Design
## Overview
This document outlines the design for extending Plotlars to support multiple plotting backends (Plotly, plotters, etc.) while maintaining a unified API for users.
## Goals
1. **Unified API**: Users should use the same API regardless of the backend
2. **Zero-cost abstraction**: No runtime overhead for backend selection
3. **Compile-time selection**: Only chosen backends are compiled
4. **Type safety**: Compiler-enforced backend compatibility
5. **Extensibility**: Easy addition of new backends
## Architecture
### 1. Backend Trait Abstraction
Define a trait that all backends must implement:
```rust
// src/backend/mod.rs
pub trait PlotBackend {
type Error;
type Output;
fn render_scatter(&self, config: &ScatterConfig) -> Result<Self::Output, Self::Error>;
fn render_line(&self, config: &LineConfig) -> Result<Self::Output, Self::Error>;
fn render_bar(&self, config: &BarConfig) -> Result<Self::Output, Self::Error>;
fn render_histogram(&self, config: &HistogramConfig) -> Result<Self::Output, Self::Error>;
fn render_box(&self, config: &BoxConfig) -> Result<Self::Output, Self::Error>;
fn render_pie(&self, config: &PieConfig) -> Result<Self::Output, Self::Error>;
// Add other plot types as needed
fn show(&self, output: Self::Output) -> Result<(), Self::Error>;
fn save(&self, output: Self::Output, path: &str) -> Result<(), Self::Error>;
fn write_image(&self, output: Self::Output, path: &str, width: u32, height: u32, scale: f64)
-> Result<(), Self::Error>;
}
```
### 2. Backend-Agnostic Configuration
Keep plot configurations independent of any specific backend:
```rust
// src/config/scatter.rs
use polars::prelude::DataFrame;
use crate::common::Rgb;
pub struct ScatterConfig {
pub data: DataFrame,
pub x: String,
pub y: String,
pub group: Option<String>,
pub colors: Vec<Rgb>,
pub size: f64,
pub opacity: f64,
pub plot_title: Option<String>,
pub x_title: Option<String>,
pub y_title: Option<String>,
pub legend_title: Option<String>,
pub marker_shape: Option<MarkerShape>,
// Add other common configuration options
}
// src/config/line.rs
pub struct LineConfig {
pub data: DataFrame,
pub x: String,
pub y: String,
pub group: Option<String>,
pub colors: Vec<Rgb>,
pub line_width: f64,
pub line_style: LineStyle,
// ... other settings
}
// Similar configs for other plot types
```
### 3. Cargo Features for Backend Selection
```toml
# Cargo.toml
[package]
name = "plotlars"
version = "0.4.0"
edition = "2021"
[features]
default = ["plotly-backend"]
plotly-backend = ["plotly", "plotly-kaleido"]
plotters-backend = ["plotters"]
all-backends = ["plotly-backend", "plotters-backend"]
# Export features
export-default = ["plotly-backend"]
export-chrome = ["plotly-backend", "plotly-kaleido/chrome"]
export-firefox = ["plotly-backend", "plotly-kaleido/firefox"]
[dependencies]
polars = "0.x"
# Backend dependencies (optional)
plotly = { version = "0.x", optional = true }
plotly-kaleido = { version = "0.x", optional = true }
plotters = { version = "0.x", optional = true }
# Common dependencies
serde = "1.0"
serde_json = "1.0"
```
### 4. Backend Implementations
#### Plotly Backend
```rust
// src/backend/plotly.rs
#[cfg(feature = "plotly-backend")]
use plotly::{Plot as PlotlyPlot, Scatter, Layout};
use crate::config::ScatterConfig;
use crate::backend::PlotBackend;
#[cfg(feature = "plotly-backend")]
pub struct PlotlyBackend {
pub renderer: PlotlyRenderer,
}
#[cfg(feature = "plotly-backend")]
pub enum PlotlyRenderer {
Browser,
Notebook,
}
#[cfg(feature = "plotly-backend")]
impl Default for PlotlyBackend {
fn default() -> Self {
Self {
renderer: PlotlyRenderer::Browser,
}
}
}
#[cfg(feature = "plotly-backend")]
impl PlotBackend for PlotlyBackend {
type Error = Box<dyn std::error::Error>;
type Output = PlotlyPlot;
fn render_scatter(&self, config: &ScatterConfig) -> Result<Self::Output, Self::Error> {
// Convert config to Plotly-specific implementation
let mut plot = PlotlyPlot::new();
// Extract data and create traces
// ... implementation similar to your current code
Ok(plot)
}
fn show(&self, output: Self::Output) -> Result<(), Self::Error> {
match self.renderer {
PlotlyRenderer::Browser => output.show(),
PlotlyRenderer::Notebook => output.notebook_display(),
}
Ok(())
}
fn save(&self, output: Self::Output, path: &str) -> Result<(), Self::Error> {
output.write_html(path);
Ok(())
}
fn write_image(
&self,
output: Self::Output,
path: &str,
width: u32,
height: u32,
scale: f64,
) -> Result<(), Self::Error> {
#[cfg(feature = "plotly-kaleido")]
{
output.write_image(path, plotly::ImageFormat::PNG, width, height, scale);
Ok(())
}
#[cfg(not(feature = "plotly-kaleido"))]
{
Err("Image export requires the 'export-default' feature".into())
}
}
// Implement other plot types...
}
```
#### Plotters Backend
```rust
// src/backend/plotters_impl.rs
#[cfg(feature = "plotters-backend")]
use plotters::prelude::*;
use crate::config::ScatterConfig;
use crate::backend::PlotBackend;
#[cfg(feature = "plotters-backend")]
pub struct PlottersBackend {
pub width: u32,
pub height: u32,
pub output_type: PlottersOutput,
}
#[cfg(feature = "plotters-backend")]
pub enum PlottersOutput {
Png,
Svg,
Bitmap,
}
#[cfg(feature = "plotters-backend")]
impl Default for PlottersBackend {
fn default() -> Self {
Self {
width: 800,
height: 600,
output_type: PlottersOutput::Png,
}
}
}
#[cfg(feature = "plotters-backend")]
impl PlotBackend for PlottersBackend {
type Error = Box<dyn std::error::Error>;
type Output = Vec<u8>;
fn render_scatter(&self, config: &ScatterConfig) -> Result<Self::Output, Self::Error> {
let mut buffer = Vec::new();
{
let root = BitMapBackend::with_buffer(&mut buffer, (self.width, self.height))
.into_drawing_area();
root.fill(&WHITE)?;
// Extract data ranges
// Build chart
// Add scatter points
// ... plotters-specific implementation
}
Ok(buffer)
}
fn show(&self, output: Self::Output) -> Result<(), Self::Error> {
// For plotters, we might save to a temp file and open it
let temp_path = "/tmp/plotlars_temp.png";
std::fs::write(temp_path, output)?;
#[cfg(target_os = "linux")]
std::process::Command::new("xdg-open").arg(temp_path).spawn()?;
#[cfg(target_os = "macos")]
std::process::Command::new("open").arg(temp_path).spawn()?;
#[cfg(target_os = "windows")]
std::process::Command::new("cmd").args(&["/C", "start", temp_path]).spawn()?;
Ok(())
}
fn save(&self, output: Self::Output, path: &str) -> Result<(), Self::Error> {
std::fs::write(path, output)?;
Ok(())
}
fn write_image(
&self,
output: Self::Output,
path: &str,
_width: u32,
_height: u32,
_scale: f64,
) -> Result<(), Self::Error> {
// For plotters, the output is already an image
std::fs::write(path, output)?;
Ok(())
}
// Implement other plot types...
}
```
### 5. User-Facing API with Generic Backend
```rust
// src/plots/scatter.rs
use crate::backend::PlotBackend;
use crate::config::ScatterConfig;
pub struct ScatterPlot<B: PlotBackend> {
config: ScatterConfig,
backend: B,
}
impl<B: PlotBackend> ScatterPlot<B> {
pub fn with_backend(backend: B) -> ScatterPlotBuilder<B> {
ScatterPlotBuilder::new(backend)
}
pub fn plot(self) -> Result<(), B::Error> {
let output = self.backend.render_scatter(&self.config)?;
self.backend.show(output)
}
pub fn save(self, path: &str) -> Result<(), B::Error> {
let output = self.backend.render_scatter(&self.config)?;
self.backend.save(output, path)
}
pub fn write_image(
self,
path: &str,
width: u32,
height: u32,
scale: f64,
) -> Result<(), B::Error> {
let output = self.backend.render_scatter(&self.config)?;
self.backend.write_image(output, path, width, height, scale)
}
}
// Default implementation for the default backend
impl ScatterPlot<crate::DefaultBackend> {
pub fn builder() -> ScatterPlotBuilder<crate::DefaultBackend> {
ScatterPlotBuilder::default()
}
}
pub struct ScatterPlotBuilder<B: PlotBackend> {
config: ScatterConfig,
backend: B,
}
impl<B: PlotBackend> ScatterPlotBuilder<B> {
pub fn new(backend: B) -> Self {
Self {
config: ScatterConfig::default(),
backend,
}
}
pub fn data(mut self, data: &DataFrame) -> Self {
self.config.data = data.clone();
self
}
pub fn x(mut self, x: &str) -> Self {
self.config.x = x.to_string();
self
}
pub fn y(mut self, y: &str) -> Self {
self.config.y = y.to_string();
self
}
pub fn group(mut self, group: &str) -> Self {
self.config.group = Some(group.to_string());
self
}
pub fn colors(mut self, colors: Vec<Rgb>) -> Self {
self.config.colors = colors;
self
}
pub fn size(mut self, size: f64) -> Self {
self.config.size = size;
self
}
pub fn opacity(mut self, opacity: f64) -> Self {
self.config.opacity = opacity;
self
}
pub fn plot_title(mut self, title: &str) -> Self {
self.config.plot_title = Some(title.to_string());
self
}
pub fn x_title(mut self, title: &str) -> Self {
self.config.x_title = Some(title.to_string());
self
}
pub fn y_title(mut self, title: &str) -> Self {
self.config.y_title = Some(title.to_string());
self
}
pub fn legend_title(mut self, title: &str) -> Self {
self.config.legend_title = Some(title.to_string());
self
}
pub fn build(self) -> ScatterPlot<B> {
ScatterPlot {
config: self.config,
backend: self.backend,
}
}
}
impl<B: PlotBackend + Default> Default for ScatterPlotBuilder<B> {
fn default() -> Self {
Self::new(B::default())
}
}
```
### 6. Library Entry Point
```rust
// src/lib.rs
pub mod backend;
pub mod config;
pub mod plots;
pub mod common;
// Re-export common types
pub use common::{Rgb, Plot};
// Define default backend based on features
#[cfg(feature = "plotly-backend")]
pub type DefaultBackend = backend::PlotlyBackend;
#[cfg(all(feature = "plotters-backend", not(feature = "plotly-backend")))]
pub type DefaultBackend = backend::PlottersBackend;
// Re-export plot types
pub use plots::{
ScatterPlot,
LinePlot,
BarPlot,
// ... other plots
};
// Re-export backends for explicit selection
pub use backend::{
#[cfg(feature = "plotly-backend")]
PlotlyBackend,
#[cfg(feature = "plotters-backend")]
PlottersBackend,
};
```
## Usage Examples
### Example 1: Using Default Backend (Plotly)
```rust
use plotlars::{ScatterPlot, Plot, Rgb};
use polars::prelude::*;
fn main() {
let dataset = LazyCsvReader::new(PlPath::new("data/penguins.csv"))
.finish().unwrap()
.select([
col("species"),
col("flipper_length_mm").cast(DataType::Int16),
col("body_mass_g").cast(DataType::Int16),
])
.collect().unwrap();
ScatterPlot::builder()
.data(&dataset)
.x("body_mass_g")
.y("flipper_length_mm")
.group("species")
.opacity(0.5)
.size(12)
.colors(vec![
Rgb(178, 34, 34),
Rgb(65, 105, 225),
Rgb(255, 140, 0),
])
.plot_title("Penguin Flipper Length vs Body Mass")
.x_title("Body Mass (g)")
.y_title("Flipper Length (mm)")
.legend_title("Species")
.build()
.plot()
.unwrap();
}
```
### Example 2: Explicitly Choosing Plotters Backend
```rust
use plotlars::{ScatterPlot, Plot, Rgb};
use plotlars::PlottersBackend;
use polars::prelude::*;
fn main() {
let dataset = /* ... load data ... */;
let backend = PlottersBackend {
width: 1200,
height: 800,
output_type: plotlars::backend::PlottersOutput::Png,
};
ScatterPlot::with_backend(backend)
.data(&dataset)
.x("body_mass_g")
.y("flipper_length_mm")
.group("species")
.build()
.save("output.png")
.unwrap();
}
```
### Example 3: Switching Backends at Compile Time
```toml
# In user's Cargo.toml
# Use Plotly (default)
[dependencies]
plotlars = "0.4"
# Or use plotters
[dependencies]
plotlars = { version = "0.4", default-features = false, features = ["plotters-backend"] }
# Or use both
[dependencies]
plotlars = { version = "0.4", features = ["all-backends"] }
```
### Example 4: Type Aliases for Convenience
```rust
// In plotlars lib.rs
pub mod prelude {
pub use crate::plots::*;
pub use crate::common::*;
#[cfg(feature = "plotly-backend")]
pub type PlotlyScatter = crate::plots::ScatterPlot<crate::backend::PlotlyBackend>;
#[cfg(feature = "plotters-backend")]
pub type PlottersScatter = crate::plots::ScatterPlot<crate::backend::PlottersBackend>;
}
// Usage
use plotlars::prelude::*;
fn create_both_plots(data: &DataFrame) {
// Create with Plotly
PlotlyScatter::builder()
.data(data)
.x("x")
.y("y")
.build()
.plot()
.unwrap();
// Create with plotters
PlottersScatter::builder()
.data(data)
.x("x")
.y("y")
.build()
.save("plot.png")
.unwrap();
}
```
## Key Benefits
1. **Unified API**: Users write the same builder pattern code regardless of backend
2. **Zero-cost abstraction**: Rust's monomorphization eliminates runtime overhead
3. **Compile-time selection**: Only chosen backends are compiled into the binary
4. **Type safety**: Compiler enforces backend compatibility at compile time
5. **Extensibility**: Adding new backends (matplotlib via PyO3, vega-lite, etc.) is straightforward
6. **Backward compatibility**: Existing code continues to work without changes
## Migration Path
To maintain backward compatibility during the transition:
```rust
// Keep existing implementation available
#[deprecated(since = "0.4.0", note = "Use the new backend-agnostic API instead")]
pub mod legacy {
// Re-export v0.3.x API
}
// New backend-agnostic API is the default
pub use plots::*;
// Provide migration guide in documentation
```
## Testing Strategy
```rust
// tests/backend_tests.rs
#[cfg(feature = "plotly-backend")]
mod plotly_tests {
use plotlars::*;
#[test]
fn test_plotly_scatter() {
let plot = ScatterPlot::builder()
.data(&test_data())
.x("x")
.y("y")
.build();
assert!(plot.save("test_plotly.html").is_ok());
}
}
#[cfg(feature = "plotters-backend")]
mod plotters_tests {
use plotlars::*;
#[test]
fn test_plotters_scatter() {
let backend = PlottersBackend::default();
let plot = ScatterPlot::with_backend(backend)
.data(&test_data())
.x("x")
.y("y")
.build();
assert!(plot.save("test_plotters.png").is_ok());
}
}
```
## Future Extensions
Potential backends to add:
- **matplotlib** (via PyO3): For Python interoperability
- **vega-lite**: For declarative grammar of graphics
- **resvg**: For high-quality SVG rendering
- **egui**: For interactive desktop applications
- **Terminal backend**: ASCII art plots for CLI tools
## Implementation Roadmap
1. **Phase 1**: Define trait and config structures
2. **Phase 2**: Refactor existing Plotly code to implement the trait
3. **Phase 3**: Add plotters backend implementation
4. **Phase 4**: Update documentation and examples
5. **Phase 5**: Add comprehensive tests for both backends
6. **Phase 6**: Release v0.4.0 with multi-backend support
## References
This pattern is successfully used by several Rust crates:
- `image`: Multiple image format backends
- `sqlx`: Multiple database backends
- `tonic`: Multiple transport backends
- `reqwest`: Multiple HTTP backends