Installation
Quick Start
Interactive Notebooks
The Life of a Chart
A chart in Charton is more than just a static image; it is the end product of a high-speed data transformation pipeline. From the moment you load a raw CSV to the millisecond a pixel lights up on your GPU, data undergoes a series of rigorous stages—validation, mapping, and hardware acceleration.
This section traces the "biography" of a chart, revealing how Charton's columnar architecture ensures that even millions of data points move through this lifecycle with near-zero latency.
Data Pipeline: From Bytes to Pixels
Charton follows a strictly columnar, one-way data pipeline designed for maximum throughput and GPU efficiency. The journey consists of five distinct stages:
1. Ingestion (ToDataset)
Data enters the system through the ToDataset trait. Whether originating from a CSV, a Polars DataFrame, or a simple Vec, data is transformed into a Columnar Dataset. This stage performs type-checking and builds Validity Bitmaps to track missing values (null) without bloating memory.
2. Encoding (Visual Mapping)
In this phase, the user defines the "Grammar of Graphics." You map semantic data columns to visual channels (Encodings).
- Example: Map the
timestampcolumn to the X-Axis and thevaluecolumn to the Y-Axis. - Charton validates these mappings against the Dataset Schema to ensure type compatibility before rendering begins.
3. Extraction(Zero-Copy Retrieval)
When the renderer prepares a frame, it requests data from the Dataset using get_column_<T>. Because Charton uses a columnar layout, this returns a direct slice (&[T]) of contiguous memory. There is no row-by-row iteration or unnecessary cloning at this stage, keeping CPU cache hits high.
4. Uploading (GPU Buffering)
The extracted memory slices are uploaded directly to WGPU Vertex Buffers. For types like f64 or f32, this is often a raw memory copy (Bit-casting), which is the fastest possible way to move data from CPU RAM to GPU VRAM.
5. Drawing (Hardware Acceleration)
Finally, the GPU executes specialized Shaders. Using the uploaded buffers, the graphics hardware parallelizes the rendering process, instantly drawing hundreds of thousands (or millions) of data points as triangles, lines, or points on the screen.
The Nanosecond Contract (i64)
Charton adopts a strict Single-Truth Input Policy for temporal data. To ensure maximum performance and eliminate ambiguity, the TemporalScale exclusively accepts 64-bit signed integers (i64) representing Unix Nanoseconds.
- Epoch: 1970-01-01 00:00:00 UTC.
- Unit: $1 \text{ nanosecond} = 10^{-9} \text{ seconds}$.
By standardizing on the finest common granularity in modern computing, Charton avoids the overhead of unit conversion and provides a predictable interface for high-frequency data.
Interoperability with Polars/Arrow
This design is intentionally aligned with the Apache Arrow memory model and Polars datetime[ns] series.
- Zero-Copy Potential: Since Polars stores datetime data as
i64nanoseconds internally, Charton can ingest large datasets from DataFrames with near-zero transformation cost. - Ecosystem Harmony: Users working with Rust's data science stack can pass raw underlying buffers directly into Charton, bypassing expensive string parsing or object construction.
Temporal Boundaries (The 292-Year Limit)
Using i64 for nanoseconds introduces a physical boundary for "Calendar-aware" time:
- Lower Bound: ~1677-09-21 (Unix -9,223,372,036,854,775,808 ns)
- Upper Bound: ~2262-04-11 (Unix 9,223,372,036,854,775,807 ns)
For 99% of modern applications—including financial history, IoT logs, and human lifespans—this range is more than sufficient. Data falling outside this range is treated as Deep Time, which triggers a semantic fallback to numerical scaling (see Section 2.1.2).
Encodings & Channels
Precision-Preserving i128 Math
Mapping a 19-digit nanosecond timestamp directly to a screen pixel via f32 or even f64 introduces Bit-Drift. At large Unix offsets (e.g., year 2026), f64 lacks the sufficient mantissa bits to distinguish between nanosecond intervals, resulting in a "staircase effect" or jittery rendering during high-zoom interactions.
Charton solves this by performing all internal domain calculations in i128 space before converting to a normalized ratio.
Relative Anchoring & Jitter Prevention
To maintain sub-pixel accuracy, the engine utilizes a Local Anchor Strategy. Instead of calculating global coordinates, it calculates positions relative to the current view's minimum value ($T_{anchor}$):
| Step | Operation | Logic |
|---|---|---|
| 1. Anchoring | $Toffset=(Tcurrent−Tanchor)$ | Integer subtraction in i128 |
| 2. Normalizing | $Ratio=Toffset(f64)/Range(f64)$ | Safe float division |
| 3. Projecting | $Pixel=Ratio×ViewportSize$ | Map to $f32$ for GPU |
By subtracting the large epoch offset in the integer domain first, the resulting delta is small enough to be represented with perfect fidelity in an f64 ratio, ensuring a smooth rendering experience even at microsecond-level zoom.
Automatic Scale Degradation (Cosmic Scales)
When the temporal span exceeds the i64 limit or the capabilities of standard calendar libraries (e.g., spans of millions of years), the Temporal Engine undergoes Semantic Degradation:
- Calendar Mode: Active for spans fitting within the
i64range. Supports leap years, months, and weekdays via thetimecrate. - Numerical Mode (Deep Time): Active for cosmic or geological scales. The engine stops attempting to format "Tuesdays" or "Months" and treats the input as a raw numerical axis (e.g., "13.8 Billion Years"), using scientific notation or custom unit-based labels.
Density-Based Interval Selection
The biggest challenge in temporal axes is preventing label overlap while maintaining a "natural" rhythm. Charton utilizes a Heuristic Step-Ladder to choose the most appropriate time interval based on the current zoom level and physical pixel density.
The engine calculates the Required_Seconds_Per_Tick using the formula:
$$Seconds_Per_Tick = \frac{Total_Domain_Seconds}{Viewport_Width / Min_Tick_Spacing}$$
Where Min_Tick_Spacing is typically 50-100 pixels. The engine then snaps to the nearest logical "Human Interval" from a predefined hierarchy:
- Sub-second: 1ms, 5ms, 10ms, 100ms, 500ms
- Seconds/Minutes: 1s, 5s, 15s, 30s, 1min, 5min, 15min, 30min
- Hours/Days: 1hr, 6hr, 12hr, 1day, 1week
- Long-term: 1month, 3months, 1year, 5years, 10years
Logical Tick Alignment (The "Clean" Start)
A common pitfall in temporal scaling is starting ticks at arbitrary timestamps (e.g., 12:04:13). To ensure professional aesthetics, Charton performs Calendar Alignment:
- Step Calculation: Identify the chosen interval (e.g.,
1 hour). - Floor Alignment: The first tick is "floored" to the nearest clean boundary. If the data starts at
12:04:13, the first tick is snapped to12:00:00or13:00:00. - Stride Execution: Ticks are generated by adding the logical
Duration(using thetimecrate) rather than a fixed number of nanoseconds, ensuring that transitions over leap years or varying month lengths (28 vs. 31 days) remain mathematically and visually correct.
Contextual Label Formatting
To save horizontal space, Charton uses a Context-Aware Formatter. The label's level of detail is determined by the span of the visible domain:
|Visible Span|Format Key|Example Label|
|Years|[year]|2026, 2027|
|Months|[year]-[month]|2026-03| 2026-04|
|Days|[month]-[day]|03-25, 03-26|
|Hours/Minutes|[hour]:[minute]|15:30, 16:00|
|Seconds/Sub-sec|[hour]:[minute]:[second].[ms]|15:30:05.125|
Multi-Level Guides (Advanced)
For long-range time series, Charton supports Multi-Level Axes. For instance, an axis might show "Days" as the primary ticks and "Months" as a secondary, bolder category label below them. This prevents the user from losing the "Year/Month" context when zoomed deep into a specific "Hour" range.
Marks
Marks are the fundamental building blocks of Charton. A mark is any visible graphical primitive—points, lines, bars, areas, rectangles, arcs, text, boxplots, rules, histograms, and more.
Every chart in Charton is created by:
1. Constructing a base chart using Chart::build().
2. Selecting a mark type (e.g., mark_point(), mark_line()).
3. Adding encodings that map data fields to visual properties.
Understanding marks is essential because most visual expressiveness comes from combining marks with encodings.
What Is a Mark?
In Charton, a mark is an object that implements the core trait:
#![allow(unused)] fn main() { pub trait Mark: Clone { fn mark_type(&self) -> &'static str; fn stroke(&self) -> Option<&SingleColor> { None } fn shape(&self) -> PointShape { PointShape::Circle } fn opacity(&self) -> f64 { 1.0 } } }
Key Properties
| Property | Meaning | Provided by Trait |
|---|---|---|
mark_type | Unique identifier | required |
stroke | Outline color | default: none |
shape | Point shape | default: circle |
opacity | Transparency | default: 1.0 |
How Marks Work in Charton
A typical Charton chart:
#![allow(unused)] fn main() { Chart::build(&df)? .mark_point() .encode(( x("x"), y("y") ))? }
Flow of Rendering
1. mark_point() creates a MarkPoint object.
2. Encodings specify how data fields map to visual properties.
3. Renderer merges:
- mark defaults
- overriding encoding mappings
- automatic palettes
4. The final SVG/PNG is generated.
Declarative Design Philosophy
Charton follows an Altair-style declarative model:
If an encoding exists → encoding overrides mark defaults.
If an encoding does not exist → use the mark’s own default appearance.
This gives you:
- Short expressions for common charts
- Fine-grained control when needed
Point Mark
MarkPoint draws scattered points.
Struct (simplified)
#![allow(unused)] fn main() { pub struct MarkPoint { pub color: Option<SingleColor>, pub shape: PointShape, pub size: f64, pub opacity: f64, pub stroke: Option<SingleColor>, pub stroke_width: f64, } }
Use Cases
- Scatter plots
- Bubble charts
- Highlighting specific points
- Overlaying markers on other marks
Correct Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_point() .encode(( x("sepal_length"), y("sepal_width"), color("species"), size("petal_length") ))? }
Line Mark
MarkLine draws connected lines.
Highlights
- Supports LOESS smoothing
- Supports interpolation
Struct
#![allow(unused)] fn main() { pub struct MarkLine { pub color: Option<SingleColor>, pub stroke_width: f64, pub opacity: f64, pub use_loess: bool, pub loess_bandwidth: f64, pub interpolation: PathInterpolation, } }
Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_line().transform_loess(0.3) .encode(( x("data"), y("value"), color("category") ))? }
Bar Mark
A bar mark visualizes categorical comparisons.
Struct
#![allow(unused)] fn main() { pub struct MarkBar { pub color: Option<SingleColor>, pub opacity: f64, pub stroke: Option<SingleColor>, pub stroke_width: f64, pub width: f64, pub spacing: f64, pub span: f64, } }
Use Cases
- Vertical bars
- Grouped bars
- Stacked bars
- Horizontal bars
Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_bar() .encode(( x("type"), y("value"), ))? }
Area Mark
Area marks fill the area under a line.
Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_area() .encode(( x("time"), y("value"), color("group") ))? }
Arc Mark (Pie/Donut)
Arc marks draw circular segments.
Example (donut)
#![allow(unused)] fn main() { Chart::build(&df)? .mark_arc() // Use arc mark for pie charts .encode(( theta("value"), // theta encoding for pie slices color("category"), // color encoding for different segments ))? .with_inner_radius_ratio(0.5) // Creates a donut chart }
Rect Mark (Heatmap)
Used for heatmaps and 2D densities.
Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_rect() .encode(( x("x"), y("y"), color("value"), ))? }
Boxplot Mark
Visualizes statistical distributions.
Example
#![allow(unused)] fn main() { Chart::build(&df_melted)? .mark_boxplot() .encode(( x("variable"), y("value"), color("species") ))? }
ErrorBar Mark
Represents uncertainty intervals.
Example
#![allow(unused)] fn main() { // Create error bar chart using transform_calculate to add min/max values Chart::build(&df)? // Use transform_calculate to create ymin and ymax columns based on fixed std values .transform_calculate( (col("value") - col("value_std")).alias("value_min"), // ymin = y - std (col("value") + col("value_std")).alias("value_max") // ymax = y + std )? .mark_errorbar() .encode(( x("type"), y("value_min"), y2("value_max") ))? }
Histogram Mark
Internally used to draw histogram bins.
Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_hist() .encode(( x("value"), y("count").with_normalize(true), color("variable") ))? }
Rule Mark
Draws reference lines.
Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_rule() .encode(( x("x"), y("y"), y2("y2"), color("color"), ))? }
Text Mark
Places textual annotations.
Example
#![allow(unused)] fn main() { Chart::build(&df)? .mark_text().with_text_size(16.0) .encode(( x("GDP"), y("Population"), text("Country"), color("Continent"), ))? }
Advanced Mark Configuration (Mark Styling)
While Encodings link data to visual properties, you often need to set fixed visual constants for a specific layer—for example, making all points red regardless of data, or adding a specific stroke to a line.
Charton provides a Closure-based Configuration for every mark type. This is the highest level of styling precedence.
Why Closures?
- Type Safety: You only see methods relevant to that specific mark (e.g., you can't set "stroke width" on a property that doesn't support it).
- Fluent Chaining: You can update multiple properties in a single, readable block.
- Encapsulation: Mark properties remain private to the rendering engine, accessible only through this controlled interface.
- Namespace Hygience & API Scalability
Charton’s closure-based design solves two major architectural challenges:
- Namespace Isolation: It prevents naming collisions between different mark types. For example, both
MarkPointandMarkTextcan expose a.with_size()method without ambiguity, as they exist only within their respective closures. - Avoiding API Bloat: It prevents the main
ChartandLayeredChartstructs from becoming "mega-structs" with hundreds of prefixed methods (like.with_point_shape()or.with_bar_padding()). This keeps the top-level API clean and ensures that the IDE's auto-completion remains helpful and intuitive.
The configure_xxx Pattern
Each mark has a corresponding configuration method (e.g., configure_point, configure_bar). These methods allow you to "reach inside" the mark and tweak its properties fluently.
#![allow(unused)] fn main() { Chart::build(&df)? .mark_point() // This closure provides direct access to the MarkPoint struct .configure_point(|m| { m.with_color("steelblue") .with_size(10.0) .with_stroke("white") .with_stroke_width(1.5) .with_opacity(0.8) }) .encode((x("time"), y("value")))? }
Precedence: Style vs. Encoding
It is important to remember the Override Rule:
- Mark Closures (
configure_xxx) take absolute priority. - Encodings (
encode) come second. - Theme Defaults are the fallback.
Note: If you set m.with_color("red") in a closure, any color("column_name") mapping in your encoding will be ignored for that specific property.
Common Configuration Methods
| Mark Type | Config Method | Key Properties to Tweak |
|---|---|---|
| Point | configure_point | shape,size,stroke,stroke_width |
| Line | configure_line | stroke_width,interpolate,dash_array |
| Bar | configure_bar | width,spacing,corner_radius |
| Text | configure_text | font_size,angle,align,baseline |
Summary
- Each mark defines a visual primitive.
- Marks are combined with encodings to bind data to graphics.
- Charton uses a declarative approach:
- Encodings override mark defaults.
- Palette and scales are automatically applied.
- By choosing the correct mark, you control how data is represented.
Guides: Axis & Legends
Semaglutide Weight Loss Curve (NEJM 2021)
Background
This figure is a reproduction of Figure 1A from the landmark study "Once-Weekly Semaglutide in Adults with Overweight or Obesity", published in The New England Journal of Medicine (NEJM) in 2021. The study evaluates the efficacy and safety of semaglutide as a pharmacological intervention for weight management.
The plot illustrates the mean percentage change in body weight over a 68-week period. It highlights the significant divergence in weight loss trajectories between the semaglutide group and the placebo group, both of which were conducted alongside lifestyle interventions.
Data Acquisition
The data used for this visualization was extracted from the original publication using WebPlotDigitizer.
Implementation
Using Charton’s "Grammar of Graphics" approach, we can recreate this complex clinical plot by layering multiple graphical components, enabling highly flexible and customizable visualizations with concise Rust code.
use charton::prelude::*; use std::error::Error; // The data is obtained from paper "Once-Weekly Semaglutide in Adults with Overweight or Obesity" // using [webplotdigitizer](https://automeris.io/). fn main() -> Result<(), Box<dyn Error>> { // Placebo group data (control group) let ds_placebo = vec![ // X-axis: time since randomization (weeks) ( "Weeks since Randomization", [0, 4, 8, 12, 16, 20, 28, 36, 44, 52, 60, 68].into_column(), ), // Y-axis: mean percentage change in body weight from baseline. Negative values indicate weight loss ( "Change from Baseline (%)", [ 0.00, -1.11, -1.72, -2.18, -2.54, -2.83, -2.82, -2.98, -3.24, -3.31, -3.22, -2.76, ] .into_column(), ), // Lower bound of the confidence interval (typically 95% CI) ( "lower", [ -0.042, -1.18, -1.81, -2.28, -2.66, -3.00, -3.03, -3.22, -3.49, -3.54, -3.46, -3.03, ] .into_column(), ), // Upper bound of the confidence interval (95% CI) ( "upper", [ 0.042, -1.04, -1.63, -2.08, -2.42, -2.66, -2.61, -2.74, -2.99, -3.08, -2.98, -2.49, ] .into_column(), ), ] .to_dataset()?; // Semaglutide group data (treatment group) let ds_semaglutide = vec![ ( "Weeks since Randomization", [0, 4, 8, 12, 16, 20, 28, 36, 44, 52, 60, 68].into_column(), ), ( "Change from Baseline (%)", [ 0.00, -2.27, -4.01, -5.9, -7.66, -9.46, -11.68, -13.33, -14.62, -15.47, -15.86, -15.6, ] .into_column(), ), ( "lower", [ -0.041, -2.3, -4.1, -5.98, -7.79, -9.58, -11.84, -13.55, -14.83, -15.72, -16.13, -15.86, ] .into_column(), ), ( "upper", [ 0.041, -2.24, -3.92, -5.82, -7.53, -9.34, -11.52, -13.11, -14.41, -15.22, -15.59, -15.34, ] .into_column(), ), ] .to_dataset()?; // Text labels (placed at the right side of the plot) let ds_text = vec![ ("x", [68.8, 68.8].into_column()), ("y", [-3.05, -15.86].into_column()), ("group", ["Placebo", "Semaglutide"].into_column()), ] .to_dataset()?; // Reference line (y = 0 → no weight change) let ds_reference = vec![ ("x", [0.0, 68.0].into_column()), ("y", [0.0, 0.0].into_column()), ] .to_dataset()?; // Layer 1: Placebo points (markers at each time point) let placebo_point = Chart::build(&ds_placebo)? .mark_point()? .configure_point(|p| { p.with_color("#818284") .with_shape("triangle") .with_size(5.0) }) .encode(( alt::x("Weeks since Randomization"), alt::y("Change from Baseline (%)"), ))?; // Layer 2: Placebo line (connects the points) let placebo_line = Chart::build(&ds_placebo)? .mark_line()? .configure_line(|l| l.with_color("#818284")) .encode(( alt::x("Weeks since Randomization"), alt::y("Change from Baseline (%)"), ))?; // Layer 3: Placebo error bars (confidence intervals) let placebo_errorbar = Chart::build(&ds_placebo)? .mark_errorbar()? .configure_errorbar(|e| { e.with_color("#818284") .with_cap_length(4.0) .with_stroke_width(1.5) }) .encode(( alt::x("Weeks since Randomization"), alt::y("lower"), alt::y2("upper"), ))?; // Layer 4: Placebo text label let placebo_text = Chart::build(&ds_text.head(1))? .mark_text()? .configure_text(|t| t.with_anchor("left").with_size(14.0)) .encode((alt::x("x"), alt::y("y"), alt::text("group")))?; // Layer 5: Semaglutide points let semaglutide_point = Chart::build(&ds_semaglutide)? .mark_point()? .configure_point(|p| p.with_color("#5b88c3").with_shape("square").with_size(3.0)) .encode(( alt::x("Weeks since Randomization"), alt::y("Change from Baseline (%)"), ))?; // Layer 6: Semaglutide line let semaglutide_line = Chart::build(&ds_semaglutide)? .mark_line()? .configure_line(|l| l.with_color("#5b88c3")) .encode(( alt::x("Weeks since Randomization"), alt::y("Change from Baseline (%)"), ))?; // Layer 7: Semaglutide error bars let semaglutide_errorbar = Chart::build(&ds_semaglutide)? .mark_errorbar()? .configure_errorbar(|e| { e.with_color("#5b88c3") .with_cap_length(4.0) .with_stroke_width(1.5) }) .encode(( alt::x("Weeks since Randomization"), alt::y("lower"), alt::y2("upper"), ))?; // Layer 8: Semaglutide text label let semaglutide_text = Chart::build(&ds_text.tail(1))? .mark_text()? .configure_text(|t| t.with_anchor("left").with_size(14.0)) .encode((alt::x("x"), alt::y("y"), alt::text("group")))?; // Layer 9: Reference line (baseline at 0%) let reference_line = Chart::build(&ds_reference)? .mark_line()? .configure_line(|l| l.with_dash([6.0, 6.0])) .encode((alt::x("x"), alt::y("y")))?; // Combine all layers (Grammar of Graphics composition) placebo_point .and(reference_line) .and(placebo_line) .and(placebo_errorbar) .and(placebo_text) .and(semaglutide_point) .and(semaglutide_line) .and(semaglutide_errorbar) .and(semaglutide_text) .with_x_expand(Expansion { mult: (0.00, 0.02), add: (0.0, 0.0), }) .with_y_expand(Expansion { mult: (0.15, 0.01), add: (0.0, 0.0), }) .with_size(1000, 400) .with_right_margin(0.08) .with_left_margin(0.02) .with_top_margin(0.02) .with_bottom_margin(0.03) .with_x_ticks([ 0.0, 4.0, 8.0, 12.0, 16.0, 20.0, 28.0, 36.0, 44.0, 52.0, 60.0, 68.0, ]) .with_y_ticks([ 0.0, -2.0, -4.0, -6.0, -8.0, -10.0, -12.0, -14.0, -16.0, -18.0, ]) .save("docs/src/images/weight_loss_curve.svg")?; Ok(()) }
Wasm-Driven Interactive Rendering Pipeline
Charton can be compiled to WebAssembly (WASM), bringing Rust's near-native performance to the browser. This enables a high-performance interaction model that handles large-scale datasets with lower latency than traditional JavaScript-based visualization libraries.
When a user interacts with a Charton chart compiled to Wasm, the pipeline works as follows:
- The browser captures a user event—e.g., a drag event for zooming or a brush gesture for selecting a range.
- Using
wasm-bindgen, the event details are passed into the Charton Rust core. - The Rust engine performs full or partial chart recomputation. These operations run at native-like speed inside Wasm.
- Charton generates a new SVG string or structured DOM patch representing the new view.
- The browser replaces the old SVG node with the new one.
Charton’s Wasm-driven model has several performance advantages:
1. Polars performance inside Wasm Traditional JS libraries rely on JavaScript arrays, D3 computations, or slower JS-based DataFrame libraries. Charton instead executes Polars in Wasm—offering:
- zero-copy columnar data
- vectorized operations
- multi-threaded execution (where supported)
2. Rust efficiency All chart logic—scales, encodings, transforms, layouts—is executed in compiled Rust, not interpreted JS.
Charton + Polars + wasm-bindgen — step-by-step example
Goal: expose a
draw_chart()function from Rust → returns an SVG string → JavaScript inserts that SVG into the DOM.
0) Prerequisites
- Rust toolchain (stable), with
rustup. wasm-pack(recommended) ORwasm-bindgen-cli+cargo build --target wasm32-unknown-unknown.-
Install
wasm-pack(recommended):cargo install wasm-pack
-
clang(required)- Linux:
apt install clang - Windows: Download and run the LLVM installer from LLVM Releases. During installation, select "Add LLVM to the system PATH".
- Linux:
- A simple static file server (e.g.
basic-http-serverfrom cargo,python -m http.server, orservevia npm). - Node/ npm only if you want to integrate into an NPM workflow; not required for the simple demo.
Important compatibility note (read before you start):
Many crates (especially heavy ones like polars or visualization crates) may have limited or no support for wasm32-unknown-unknown out of the box. If Polars and Charton compile to wasm in your environment, the steps below will work. If they don't, read the Caveats & alternatives section at the end.
1) Project layout
Assume you created a project:
web
├── Cargo.toml
├── index.html
├── pkg
│ ├── package.json
│ ├── web_bg.wasm
│ ├── web_bg.wasm.d.ts
│ ├── web.d.ts
│ └── web.js
└── src
└── lib.rs
We will build a cdylib wasm package that wasm-pack will wrap into pkg/.
2) Cargo.toml(example)
Put this into web/Cargo.toml.
[package]
name = "web"
version = "0.1.0"
edition = "2021" # Important: Stable standard for Wasm/Polars. Don't upgrade to 2024 yet to avoid toolchain conflicts.
# Produce a cdylib for wasm
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
polars = { version = "0.49", default-features = false }
# Avoids transitive mio dependency to ensure Wasm compatibility.
polars-io = { version = "0.49", default-features = false, features = ["parquet"] }
charton = { version = "0.4" }
[profile.release]
opt-level = "z" # or "s" to speed up
lto = true
codegen-units = 1
panic = "abort"
3) src/lib.rs-Rust (wasm entry points)
Create web/src/lib.rs.
#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; use polars::prelude::*; use charton::prelude::*; // Build a small scatter plot and return the SVG string. #[wasm_bindgen] pub fn draw_chart() -> Result<String, JsValue> { // Create a tiny DataFrame let df = df![ "length" => [5.1, 4.9, 4.7, 4.6, 5.0, 5.4, 4.6, 5.0, 4.4, 4.9], "width" => [3.5, 3.0, 3.2, 3.1, 3.6, 3.9, 3.4, 3.4, 2.9, 3.1] ].map_err(|e| JsValue::from_str(&e.to_string()))?; // Build a Charton Chart let chart = Chart::build(&df) .map_err(|e| JsValue::from_str(&e.to_string()))? .mark_point()? .encode((x("length"), y("width"))) .map_err(|e| JsValue::from_str(&e.to_string()))?; let svg = chart.to_svg() .map_err(|e| JsValue::from_str(&e.to_string()))?; // Returns SVG string Ok(svg) } }
Key points:
#[wasm_bindgen]exposes functions to JS.- We return
Result<String, JsValue>so JS receives errors as exceptions.
4) Build with wasm-pack (recommended)
From project root (web/):
wasm-pack build --release --target web --out-dir pkg
wasm-pack will:
-
compile to
wasm32-unknown-unknown, -
run
wasm-bindgento generate JS wrapper(s), -
produce a
pkg/folder containing:web_bg.wasmweb_bg.wasm.d.tsweb.d.tsweb.js(ES module bootstrap)
💡Optimization Note: Binary Size
After building in
--releasemode, the resultingweb_bg.wasmis approximately 4 MB. However, for web production:
- Gzip compression reduces it to about 900 KB.
- Brotli compression can shrink it even further. This compact footprint makes it highly suitable for browser-side data processing without long loading times.
5) Creating index.html (Client-Side Loader)
The final step is to create a minimal HTML file (web/index.html) that loads the generated WASM module and renders the SVG chart into the page.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Charton WASM Demo</title>
</head>
<body>
<div id="chart-container"></div>
<script type="module">
import init, { draw_chart } from './pkg/web.js';
async function run() {
// Initialize and load the WebAssembly module
await init();
// Call the Rust function that returns an SVG string
const svg = draw_chart();
// Insert the SVG into the page
document.getElementById("chart-container").innerHTML = svg;
}
run();
</script>
</body>
</html>
This minimal version:
- Loads the WASM module generated by
wasm-pack - Calls the Rust function
draw_chart()to generate the SVG string - Injects the SVG directly into the DOM
- Contains no additional CSS, error handling, or panic hooks — keeping the example simple and focused
This is the recommended simplest setup for demonstrating Charton rendering through WebAssembly.
6) Serve the folder
Browsers enforce CORS for WASM; open the page via HTTP server rather than file://.
Minimal options:
cd web
python -m http.server 8080
Then open http://localhost:8080/index.html and you'll see the chart in the browser:

7) Troubleshooting
Processing heavy libraries like Polars in WASM can strain your system. Here is how to handle common bottlenecks:
- Compilation Hangs/Freezes: Building Polars for WASM is extremely CPU and RAM intensive. If your computer "freezes" during the
Optimizing with wasm-optstage, you can manually stop the process. The compiled.wasmfile inpkg/is usually already functional; it will simply be larger in size without the final optimization. For a smooth experience, a machine with high-core counts and 16GB+ RAM is recommended. - wasm-opt Errors: If
wasm-packfails because it cannot install or runwasm-opt, you can simply ignore the error if thepkg/folder was already populated. The unoptimized WASM file will still run in the browser. - Polars Version Incompatibility: If your project requires a Polars version uncompatible with the one used by Charton, passing a DataFrame directly will cause a compilation error. In this case, you can use the Parquet Interoperability method described in Section 2.3.4.
Charton + Polars + wasm-bindgen — advanced example: dynamic CSV visualization
Goal: Beyond a static demo, we now build a functional tool: users upload a local CSV file (e.g., iris.csv, which can be found and downloaded from the datasets/ folder in this project) → JavaScript reads it as a string → Rust/Polars parses the data in-browser → Charton generates a multi-colored scatter plot → The resulting SVG is rendered instantly.
1) Updated Cargo.toml
Update Note: This file updates the dependencies from 9.2.2 by enabling the csv feature in polars-io (to handle user uploads) and switching to the charton crate for more advanced encoding.
[package]
name = "web"
version = "0.1.0"
edition = "2021" # Important: Stable standard for Wasm/Polars. Don't upgrade to 2024 yet to avoid toolchain conflicts.
# Produce a cdylib for wasm
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
polars = { version = "0.49", default-features = false }
# Avoids transitive mio dependency to ensure Wasm compatibility.
polars-io = { version = "0.49", default-features = false, features = ["parquet", "csv"] }
charton = { version = 0.4 }
[profile.release]
opt-level = "z" # or "s" to speed up
lto = true
codegen-units = 1
panic = "abort"
2) Updated src/lib.rs
Update Note: This replaces the hard-coded draw_chart from 9.2.2. The new draw_chart_from_csv function accepts a String from JavaScript and uses std::io::Cursor to treat that string as a readable file stream for Polars.
#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; use polars::prelude::*; use charton::prelude::*; use std::io::Cursor; #[wasm_bindgen] pub fn draw_chart_from_csv(csv_content: String) -> Result<String, JsValue> { /* * 1. Parse CSV data from String. * We use a Cursor to treat the String as a readable stream for Polars. */ let cursor = Cursor::new(csv_content); /* * 2. Initialize the Polars DataFrame. * CsvReader is highly optimized but runs in a single thread in standard WASM. */ let df = CsvReader::new(cursor) .finish() .map_err(|e| JsValue::from_str(&format!("Polars Error: {}", e)))?; /* * 3. Construct the Scatter Plot. * Ensure that the columns "length" and "width" exist in your CSV file. */ let chart = Chart::build(&df) .map_err(|e| JsValue::from_str(&e.to_string()))? .mark_point() .encode((x("sepal_length"), y("sepal_width"), color("species"))) .map_err(|e| JsValue::from_str(&e.to_string()))?; /* * 4. Generate SVG. * The to_svg() method returns a raw XML string representing the vector graphic. */ let svg = chart.to_svg() .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(svg) } }
3) Updated index.html
Update Note: This expands the simple loader from 9.2.2 by adding a File Input UI and a FileReader event loop. This allows the WASM module to process "live" data provided by the user.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WASM CSV Visualizer</title>
<style>
#chart-container { margin-top: 20px; border: 1px solid #ccc; }
</style>
</head>
<body>
<h2>Upload CSV to Generate Chart</h2>
<input type="file" id="csv-upload" accept=".csv" />
<div id="chart-container"></div>
<script type="module">
import init, { draw_chart_from_csv } from './pkg/web.js';
async function run() {
// Initialize the WASM module
await init();
const fileInput = document.getElementById("csv-upload");
const container = document.getElementById("chart-container");
// Event listener for file selection
fileInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
/* * Use FileReader to read the file content as text.
* This text is then passed across the JS-WASM boundary.
*/
const reader = new FileReader();
reader.onload = (e) => {
const csvContent = e.target.result;
try {
// Call the Rust function with the CSV string
const svg = draw_chart_from_csv(csvContent);
// Inject the returned SVG string directly into the DOM
container.innerHTML = svg;
} catch (err) {
console.error("Computation Error:", err);
alert("Error: Make sure CSV has 'length' and 'width' columns.");
}
};
// Trigger the file read
reader.readAsText(file);
});
}
run();
</script>
</body>
</html>
4) Build and Serve Update Note: The build command remains the same as 9.2.3, but the compilation time may increase due to the added CSV and color encoding features.
# Build the package
wasm-pack build --release --target web --out-dir pkg
# Serve the files
python -m http.server 8080
Summary of Improvements over 9.2.3
- Data Handling: Shifted from static
df!macros to dynamicCsvReaderparsing. - Complexity: Added
colorencoding in Charton to demonstrate multi-dimensional data mapping. - User Interaction: Introduced the
FileReaderAPI to bridge the gap between the local file system and WASM linear memory.
Conclusion
The combination of static SVG and dynamic Rust/Wasm computation forms a powerful model for interactive visualization:
- SVG provides simple, portable output for embedding and styling.
- Rust/Wasm enables high-performance chart recomputation.
- Polars accelerates data transformations dramatically.
- Browser handles final rendering efficiently.
Charton does not attempt to patch SVGs with JavaScript like traditional libraries. Instead, it regenerates a complete static SVG—fast enough to support real-time interactivity.
This architecture makes high-performance, browser-based interaction not only possible but highly efficient.
True interactive visualization via the Altair backend
Charton can generate fully interactive charts by delegating to Altair, which compiles to Vega-Lite specifications capable of:
- Hover tooltips
- Selections
- Brush interactions
- Zoom and pan
- Linked views
- Filtering and conditional styling
- Rich UI semantics
Charton’s role in this workflow
Charton does:
- Run Rust-side preprocessing (Polars)
- Transfer data to Python
- Embed user-provided Altair plotting code
- Invoke Python to generate Vega-Lite JSON
- Display the result (browser/Jupyter) or export JSON
All actual interactivity comes from Altair/Vega-Lite, not from Charton.
Example: interactive Altair chart via Charton
#![allow(unused)] fn main() { :dep charton = { version="0.3" } :dep polars = { version="0.49" } use charton::prelude::*; use polars::prelude::df; let exe_path = r"D:\Programs\miniconda3\envs\cellpy\python.exe"; let df1 = df![ "Model" => ["S1", "M1", "R2", "P8", "M4", "T5", "V1"], "Price" => [2430, 3550, 5700, 8750, 2315, 3560, 980], "Discount" => [Some(0.65), Some(0.73), Some(0.82), None, Some(0.51), None, Some(0.26)], ].unwrap(); // Any valid Altair code can be placed here. let raw_plotting_code = r#" import altair as alt chart = alt.Chart(df1).mark_point().encode( x='Price', y='Discount', color='Model', tooltip=['Model', 'Price', 'Discount'] ).interactive() # <-- zoom + pan + scroll "#; Plot::<Altair>::build(data!(&df1)?)? .with_exe_path(exe_path)? .with_plotting_code(raw_plotting_code) .show()?; // Jupyter or browser }
This provides real interactivity entirely through Altair.
Exporting Vega-Lite JSON for browser/Web app usage
Since Altair compiles to Vega-Lite, Charton can generate the JSON specification directly.
This is ideal for:
- Web dashboards
- React / Vue / Svelte components
- Embedding charts in HTML
- APIs returning visualization specs
- Reproducible visualization pipelines
Example: Export to JSON
#![allow(unused)] fn main() { let chart_json: String = Plot::<Altair>::build(data!(&df1)?)? .with_exe_path(exe_path)? .with_plotting_code(raw_plotting_code) .to_json()?; // save, embed, or send via API println!("{}", chart_json); }
The generated Vega-Lite JSON specification will look like this:
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.20.1.json",
"data": {
"name": "data-8572dbb2f2fe2e54e92fc99f68a5f076"
},
"datasets": {
"data-8572dbb2f2fe2e54e92fc99f68a5f076": [
{
"Discount": 0.65,
"Model": "S1",
"Price": 2430
},
// ... more data rows ...
]
},
"encoding": {
"color": {
"field": "Model",
"type": "nominal"
},
"x": {
"field": "Price",
"type": "quantitative"
},
// ... other encoding and properties ...
},
"mark": {
"type": "point"
}
}
Embedding in a webpage:
To render the visualization, simply embed the generated JSON into your HTML using the vega-embed library:
<div id="vis"></div>
<script>
var spec = /* paste JSON here */;
vegaEmbed('#vis', spec);
</script>
Summary: Hybrid Power
By leveraging Altair as a backend, Charton offers a unique "hybrid" workflow that combines the best of two worlds:
- Rust Efficiency: Handle heavy data crunching and complex Polars transformations with type safety and maximum performance.
- Python Ecosystem: Access the vast, mature visualization capabilities of Altair/Vega-Lite without leaving your Rust development environment.
Whether you are performing rapid Exploratory Data Analysis in a Jupyter notebook or shipping high-fidelity interactive dashboards to a web frontend, this bridge ensures you never have to choose between performance and features.