Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

wisp-chart

Data structs in, scene-graph subtrees out.

wisp-chart is the opinionated chart composition layer for wisp. It turns "rows × bars × time" (Gantt) — and eventually bar / line / area — into a wisp::scene::Node that drops straight into a wisp::Stage.

Boundary discipline

wisp-chart depends on wisp, never the reverse. Every chart-specific dependency (jiff for dates, palette types, contrast utils) lives in wisp-chart/Cargo.toml only — wisp stays a Pixi-equivalent primitive renderer with zero awareness of charts, dates, themes, or palettes.

What this book covers

  • Gantt — the v1 composition. Hyper-specific first example (one concrete year, one concrete team) so the API has zero degrees of freedom before we iterate on flexibility.
  • Web demo — running the same chart in a Chrome <canvas> via WebGPU. Same crate, same code, different surface.

What this book does not cover

  • The wisp renderer itself — see the wisp book.
  • The Screen recorder application — see the Screen book.

Three books, one site

This is the third mdBook in the Screen monorepo, mounted at /Screen/wisp-chart/. The other two:

PathBookSource
/Screen/Screen recorder_docs/book/
/Screen/wisp/wisp renderer_docs/wisp-book/
/Screen/wisp-chart/This book_docs/wisp-chart-book/

Cross-book links go through the mdbook-preprocessor-cross tags so they survive the deployed path-based routing.

Quickstart

Add wisp-chart to a workspace crate that already depends on wisp. The chart-specific dep jiff rides in via wisp-chart — don't add it to your own Cargo.toml unless you need jiff directly.

[dependencies]
wisp.workspace = true
wisp-chart = { path = "../wisp-chart" }

Build the smallest possible Gantt:

use wisp_chart::{Bar, DateRange, Gantt, Row, Theme};
use jiff::civil::date;

let chart = Gantt {
    range: DateRange::year(2026),
    rows: vec![Row::new("vec", "M-VEC")],
    bars: vec![Bar::new(
        "vec",
        date(2026, 2, 1)..date(2026, 3, 15),
        "Matt",
    )],
    people: Default::default(),
};

let node = chart.render(&Theme::light());
stage.add(node);

```admonish note title="Gantt::render lands in M-CHART.0 chunk 3" The foundation commit (chunk 1) ships the data + theme + palette modules. The render pass — Gantt::render(&Theme) -> SceneNode — follows in chunk 3. Until that chunk lands, the snippet above is illustrative; build with cargo check -p wisp-chart to confirm the data API.


## Run it in a browser

The same crate compiles for `wasm32-unknown-unknown` and
renders into a `<canvas>` via WebGPU. See
[Run wisp-chart in Chrome via WebGPU](./web-demo.md).

Where this book sits

wisp-chart is one of three mdBooks composed into the same GitHub Pages artifact.

flowchart LR
    Screen["/Screen/<br/>screen recorder"]:::shell
    Wisp["/Screen/wisp/<br/>wisp renderer"]:::wisp
    Chart["/Screen/wisp-chart/<br/>chart compositions"]:::chart
    Api["/Screen/api/<br/>rustdoc"]:::api

    Chart -->|depends on| Wisp
    Screen -->|uses| Wisp
    Screen -->|uses| Chart

    classDef shell fill:#1e293b,stroke:#475569,color:#e2e8f0
    classDef wisp fill:#7c2d12,stroke:#ea580c,color:#fed7aa
    classDef chart fill:#312e81,stroke:#6366f1,color:#e0e7ff
    classDef api fill:#374151,stroke:#9ca3af,color:#f3f4f6

Cross-book tags

The mdbook-preprocessor-cross tags resolve per-book. Inside this book:

TagRenders as
/Screen/wisp/wisp/overview.html/Screen/wisp/wisp/overview.html
./charts/gantt/api.html./charts/gantt/api.html (relative — same book)
````admonish info title="Cross-book link convention"
This is one of two sibling mdBooks deployed to the same GitHub
Pages site:
  • Project book/Screen/ (recorder + capture + encoder + Tauri shell).
  • Wisp book/Screen/wisp/ (renderer-only reference; publishable to crates.io independently).

Cross-references in either book go through the mdbook-preprocessor-cross preprocessor so URLs adapt per book:

  • \{\{wisp-link path/to/chunk\}\} — emits a relative URL inside the wisp book, an absolute /Screen/wisp/path/to/chunk.html URL from the project book.
  • \{\{shared path/to/fragment.md\}\} — inlines a markdown fragment from _docs/shared/ (this snippet you're reading is one).

Plain markdown links from the wisp book back to the project use absolute URLs (/Screen/...) since the inverse direction is single-target.

` | inlined from `_docs/shared/` |

Inside the screen + wisp books, the same `{{wisp-chart-link}}`
tag emits an absolute URL to `/Screen/wisp-chart/...`. Authors
write the tag once; the preprocessor adapts per book.

Chart gallery

Every chart wisp-chart can render in one place. Click a thumbnail to open the chapter; the live WebGPU demos are embedded inside each.

Cartesian marks

Indicators

Finance

Polar

Heatmaps

Topology + multi-view

Distributions

Compositional + fields

Gantt

Theme — shared across every chart family

wisp_chart::Theme is the single entry point that themes any chart family consistently. Pick Theme::light() once and render a Gantt, a bar chart, a KPI card, or a sunburst against it — each chart family reads only from its own sub-theme plus the top-level shared fields, so adding a new chart never reshapes existing themes.

Shape

graph LR
    T[Theme]
    T --> bg[bg : Color]
    T --> tp[text_primary : Color]
    T --> tm[text_muted : Color]
    T --> pal[palette : OwnerPalette]
    T --> plot[plot : PlotTheme]
    T --> axis[axis : AxisTheme]
    T --> leg[legend : LegendTheme]
    T --> ind[indicator : IndicatorTheme]
    T --> gantt[gantt : GanttTheme]
    classDef shared fill:#7c2d12,stroke:#ea580c,color:#fed7aa
    classDef sub fill:#1e293b,stroke:#475569,color:#cbd5e1
    class bg,tp,tm,pal shared
    class plot,axis,leg,ind,gantt sub

Top row (orange) are the shared fields every chart family may read. The five sub-themes (slate) are each owned by a specific chart-family — theme.gantt.* is for Gantt, theme.plot.* / theme.axis.* / theme.legend.* for cartesian families, and so on.

Boundary rule

A chart family reads only from its own sub-theme + the top-level shared fields. Reaching into another chart family's sub-theme is the structural coupling this decomposition exists to prevent. Gantt code reads theme.gantt.*; a future bar chart will read theme.plot.* + theme.axis.*; neither should ever touch the other's fields.

Field inventory

PlotTheme:

  • gridline_majorLineStyle (default #cccccc, 2 px). Major-tick gridlines on cartesian / heatmap charts.
  • gridline_minorLineStyle (default #e5e5e5, 1 px). Minor-tick gridlines, e.g. weeks within a month.

AxisTheme:

  • tick_length_px — tick mark length in device pixels (default 5).
  • tick_label_font_size — tick label font size (default 12).
  • tick_density_hint — target number of ticks the auto-tick generator aims for (default 8).

LegendTheme:

  • swatch_size_px — colour box / line / marker size (default 14).
  • item_spacing_px — spacing between legend items (default 8).
  • item_font_size — legend item font size (default 12).

IndicatorTheme:

  • numeric_font_size — big-number font (default 32).
  • delta_up — positive delta colour (default #27ae60 green).
  • delta_down — negative delta colour (default #e74c3c red).
  • delta_neutral — neutral colour (default #888888 muted).

GanttTheme:

  • row_alt_bg — alternating row tint (default #fafafa).
  • header_bg — header band background (default #f5f5f5).
  • grid_week / grid_month — Gantt-specific aliases for the plot gridlines (default mirrors theme.plot.gridline_minor / gridline_major).
  • bar_corner_radius — bar corner radius in pixels (default 6).
  • bar_height — bar height in pixels (default 28).
  • row_height — row height in pixels (default 44).
  • gutter_width — left gutter width (default 180).
  • header_height — header band height (default 60).

Customising

The common case is "use Theme::light() but tweak one knob":

#![allow(unused)]
fn main() {
let mut theme = wisp_chart::Theme::light();
theme.gantt.bar_corner_radius = 12.0;   // chunkier Gantt bars
theme.indicator.delta_up = wisp_chart::Color::from_hex("#10b981").unwrap();
}

Spreading a sub-theme onto a fresh Theme works too:

#![allow(unused)]
fn main() {
let theme = wisp_chart::Theme {
    gantt: wisp_chart::theme::GanttTheme {
        bar_corner_radius: 12.0,
        ..wisp_chart::Theme::light().gantt
    },
    ..wisp_chart::Theme::light()
};
}

Theme::dark() lands as a follow-on once the cartesian wave is visible enough that dark-mode contrast checks are worth doing.

Verified by

crates/wisp-chart/src/theme.rs has four tests:

  1. light_theme_uses_white_bg — top-level bg defaults to white.
  2. light_theme_gantt_dimensions_match_spec — the Gantt sub-theme preserves the pre-decomposition pixel sizes (28/44/180/60/6) byte-identically.
  3. light_theme_populates_every_sub_theme — every sub-theme has non-zero defaults where a real number is required.
  4. gantt_grid_lines_match_legacy_aliases — Gantt's grid_week / grid_month track the plot gridlines, matching the original flat-Theme shape.

Plus the chart-web snapshot test crates/wisp-chart-web/tests/render_gantt.rs continues to pass unchanged: the Gantt PNG is byte-identical after the refactor.

Scales — domain → range mappings

Every cartesian / polar / heatmap chart maps abstract domain values (numbers, categories, dates) into pixel coordinates through a Scale. Building this once means every chart family gets consistent tick placement, padding, and edge behaviour for free.

What ships in v1

graph LR
    L[LinearScale<br/>continuous f32]
    B[BandScale<br/>discrete categories]
    O[OrdinalScale<br/>category → index]
    T[TimeScale<br/>jiff::Date]
    G[LogScale<br/>positive f32]
    L --> px1[f32 pixel range]
    B --> px2[band start/end + centre]
    O --> idx[usize index]
    T --> px3[f32 pixel range]
    G --> px4[f32 pixel range]
    classDef scale fill:#1e293b,stroke:#475569,color:#cbd5e1
    classDef out fill:#7c2d12,stroke:#ea580c,color:#fed7aa
    class L,B,O,T,G scale
    class px1,px2,px3,px4,idx out
ScaleUsed by
LinearScalebar (y), line, scatter, histogram, area, KPI sparkline
BandScalebar (x), grouped bar, box plot
OrdinalScalecolour encoding lookups
TimeScaleline / area with time-x, Gantt, candlestick
LogScalebubble x (GDP), histogram of skewed data

Convention

All scales map domainrange, both as (f32, f32) tuples (or category lists for band / ordinal). The convention is:

  • range.0 corresponds to the left edge of the plot area for X scales and the bottom edge for Y scales. Callers pre-flip Y ranges so a LinearScale::new((0, 100), (plot_bottom_y, plot_top_y)) puts low values at the bottom.
  • Tick generators return the domain values that should get tick marks; the rendering layer projects each through map and draws.

Examples

LinearScale — d3-style nice-tick at 1/2/5 cadence per decade:

#![allow(unused)]
fn main() {
let x = wisp_chart::scale::LinearScale::new((0.0, 73.0), (0.0, 960.0));
let ticks = x.ticks(8);
// 0, 10, 20, 30, 40, 50, 60, 70 — step 10 chosen as the nice
// stop closest to 73/8 ≈ 9.1.
}

BandScale — discrete categories with padding:

#![allow(unused)]
fn main() {
let cat = wisp_chart::scale::BandScale::new(
    ["Q1", "Q2", "Q3", "Q4"],
    (180.0, 960.0),
).padding(0.1);
let (start, end) = cat.range_for(&"Q2").unwrap();
// Q2's band spans (start, end) with 10% gap each side.
}

TimeScale — multi-unit tick generator:

#![allow(unused)]
fn main() {
use jiff::civil::date;
use wisp_chart::gantt::DateRange;
use wisp_chart::scale::{TimeScale, TimeUnit};

let scale = TimeScale::new(DateRange::year(2026), (180.0, 960.0));
let unit = scale.pick_unit(8);          // returns TimeUnit::Month for a full year
let ticks = scale.ticks_at(unit);       // 12 month-starts
}

LogScale — 1/2/5 stops per decade:

#![allow(unused)]
fn main() {
let s = wisp_chart::scale::LogScale::new((1.0, 1_000.0), (0.0, 600.0));
// Ticks at 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000.
}

What doesn't ship in v1

  • Categorical sorting strategiesOrdinalScale keeps insertion order; explicit sort policies (alphabetical, by-count) follow as a chart-specific concern.
  • Non-natural-log bases for LogScale — base-10 default; the tick generator's 1/2/5 cadence is tuned for decimal. Other bases work for map but tick spacing won't match the base.
  • Time-axis localisation / week-start configuration — Mondays are the assumed week start, matching the existing Gantt convention.

Verified by

crates/wisp-chart/src/scale/*.rs — 37 unit tests across the five scale types covering: round-trip mapping, reversed-range handling, tick generation at nice stops, padding clamping, edge cases (zero-width domain, missing categories, dates outside range). All pass under cargo test -p wisp-chart --lib.

Plot facade — grammar of graphics

Plot is the ergonomic front door for cartesian / polar / heatmap chart families. Build a chart by composing data + a mark

  • encodings:
#![allow(unused)]
fn main() {
use wisp_chart::plot::{self, DataFrame, Mark, Plot, ScaleKind, Value};

let plot = Plot::new(df)
    .mark(Mark::Bar { value_labels: false })
    .encode(plot::x("quarter", ScaleKind::Band))
    .encode(plot::y("revenue", ScaleKind::Linear))
    .encode(plot::color("region"));
let graphics = plot.render(&theme, viewport_px);
}

That's the entire surface. Swap Mark::Bar for Mark::Line (when it ships) and the same data + encodings render as a line chart.

bar chart rendered via the Plot facade

Pieces

graph LR
    R[user rows] -->|from_rows + flatten| DF[DataFrame<br/>column-oriented]
    DF -->|encode| ENC[Encoding<br/>X / Y / Color]
    ENC -->|scale_kind| SK[ScaleKind<br/>Linear / Band / Ordinal / Time / Log]
    ENC --> P[Plot]
    M[Mark<br/>Bar / Line / Area / ...] --> P
    T[Theme] --> P
    P -->|render| G[wisp::Graphics]
    classDef data fill:#7c2d12,stroke:#ea580c,color:#fed7aa
    classDef api fill:#1e293b,stroke:#475569,color:#cbd5e1
    classDef out fill:#14532d,stroke:#16a34a,color:#bbf7d0
    class R,DF data
    class ENC,SK,M,T,P api
    class G out

DataFrame

User rows of any R type are flattened once via a closure into a column-oriented DataFrame. Each column is either Value::Number(f32) (feeds Linear / Log / Y scales) or Value::Category(String) (feeds Band / Ordinal / Color encodings). After the flatten, every encoding refers to columns by name.

#![allow(unused)]
fn main() {
struct Sale { quarter: String, revenue: f32, region: String }
let df = DataFrame::from_rows(&rows, |s| vec![
    ("quarter".into(), Value::Category(s.quarter.clone())),
    ("revenue".into(), Value::Number(s.revenue)),
    ("region".into(),  Value::Category(s.region.clone())),
]);
}

Trade-off: pay an O(n × cols) flatten cost on construction, gain string-based encoding ergonomics + automatic scale-derivation from column values.

Mark

Mark is the drawable shape a Plot emits per row. v1 ships:

  • Mark::Bar { value_labels: bool } — rectangular bar, one per row. value_labels toggle is parsed but not rendered yet (lands when the axis renderer arrives).

Follow-on tickets add Line, Area, Point, Cell, Box, Candlestick, Polygon, Arc — each is one variant on this enum + one renderer arm in Plot::render. The facade surface stays stable.

Encoding

An Encoding wires one Channel (X / Y / Color) to one DataFrame column with a chosen ScaleKind. Convenience constructors:

HelperChannelDefault scale
plot::x(field, scale_kind)Xas specified
plot::y(field, scale_kind)Yas specified
plot::color(field)ColorOrdinal

Domain auto-derives from the column values:

  • Linear reads numeric_extent (column min/max).
  • Band / Ordinal read distinct_categories (in insertion order).
  • Override the numeric domain explicitly with .domain((lo, hi)) on the Encoding.

Color encoding in v1

The Color encoding is stored on the Plot but Mark::Bar v1 ignores it — single-color bars only. When Mark::Bar is upgraded to multi-series (M-CHART.7 Grouped Bar / M-CHART.8 Stacked Bar) the Color encoding wires into palette lookup. Single-series charts get their colour from theme.palette via the X category hash.

What's deferred to the v1 follow-ups

This Plot facade is layout-stable but visually minimal for v1. The chart-side TODOs that share this same render path:

  • Axis renderer (AUT-183 / M-CHART.3) — emits tick lines + tick labels + axis titles. The plot area today reserves the gutter / header / footer pixels so axes can drop in without shifting bars.
  • Legend renderer (AUT-184 / M-CHART.4) — colour swatches + labels for multi-series charts.
  • Value labels on bars — currently inert (Mark::Bar carries the flag).

All three land as commits that extend Plot::render without changing the public API.

Verified by

  • crates/wisp-chart/src/plot/*.rs13 unit tests covering DataFrame construction, distinct-category de-dup, numeric extent, encoding chain, channel replacement, NDC conversion, background-only fallback when encodings are missing.
  • crates/wisp-chart-web/tests/render_bar.rsintegration test: builds a 4-quarter DataFrame, runs the full Plot::new → mark → encode → render chain, renders via wisp::Renderer to an offscreen Rgba8Unorm texture, and asserts (1) header area is white, (2) the tallest bar's centre pixel is non-white. Also regenerates the bar-quarterly.png snapshot above.

What this unblocks

Every cartesian / polar / heatmap chart now has a stable surface to land against. The chart wave can proceed in parallel:

  • M-CHART.6 Bar variants (grouped / stacked / 100%): add encodings (XOffset, Stack transform).
  • M-CHART.9 Line, M-CHART.10 Area, M-CHART.11 Scatter, M-CHART.12 Bubble, M-CHART.13 Connected scatter: add Mark variants + renderer arms.
  • M-CHART.20+ statistical charts: add Transform enum (Bin, Density, Stack, RunningTotal) that runs before marks emit.

Axes

Axes are the gridlines, tick marks, tick labels, and axis titles that frame a cartesian plot. The Plot facade emits them automatically; this chapter documents the renderer behind that default + the knobs the caller has.

Bar chart with default axes

What ships with the default render

Info

A Plot rendered with default settings emits, in this order: background → Y gridlines + Y axis line + Y tick marks → X gridlines + X axis line + X tick marks → marks (bars, lines) → tick labels + axis title.

The order matters: gridlines + tick marks emit BEFORE marks so data primitives composite on top of the grid. Tick labels emit AFTER marks so they are never occluded by a tall bar.

Public surface

The renderer's primitives live in wisp_chart::axis. Two pairs of functions, one pair per axis direction:

FunctionReturnsPurpose
emit_x_axis_lineswisp::GraphicsBottom/top axis line + ticks + grid
emit_y_axis_lineswisp::GraphicsLeft/right axis line + ticks + grid
emit_x_axis_textVec<wisp::Text>Tick labels + X-axis title
emit_y_axis_textVec<wisp::Text>Tick labels + Y-axis title (rotated -π/2)

The lines/text split exists because wisp::Graphics and wisp::Text are different node types — Graphics is composable via Graphics::append, Text needs a Font instance.

Why Plot needs axis_text_labels separately

Plot::render returns a single Graphics subtree. It cannot emit Text nodes because text rendering needs a wisp::Font, which depends on a live wgpu device. To keep wisp-chart's public API device-free, the caller supplies the font:

let plot = Plot::new(df)
    .x_title("Quarter")
    .y_title("Revenue")
    .encode(plot::x("quarter", ScaleKind::Band))
    .encode(plot::y("revenue", ScaleKind::Linear));

// Marks + axis lines:
let graphics = plot.render(&theme, viewport);
let root = stage.root();
let _ = stage.add_child(root, graphics);

// Axis text — needs the Font:
let font = Font::bitmap_8x8(&app);
for text in plot.axis_text_labels(&theme, viewport, &font) {
    let _ = stage.add_child(root, text);
}

Disabling axes

Tip

For minimalist renders (sparklines, design previews), call .axes(false) to skip every axis primitive. Useful when the chart is embedded inside a card UI and the parent component draws its own framing.

let plot = Plot::new(df).axes(false).mark(Mark::Bar { value_labels: false });

Coordinate convention

Warning

The axis renderer takes pixel-space tick positions (top-left origin, +Y down) and emits NDC primitives (+Y up). This matches wisp's convention; wisp::math::Rect doc says +Y down in source code but the renderer flips. Always think in pixels in the chart layer; the conversion happens inside emit_*_axis_*.

Legend

A legend maps a categorical Color encoding's values to their palette swatches. The Plot facade exposes Plot::legend(theme) to auto-build one from the data; callers can also construct a Legend from scratch when the chart isn't a standard Plot.

Public surface

TypePurpose
LegendThe composed legend value
LegendItemOne swatch + label pair
SwatchStyleColorBox / LineSample / PointMarker
LegendOrientationVertical / Horizontal

Swatch styles

Info

The mark type drives swatch style: bars / areas / cells use ColorBox; line + trend marks use LineSample; scatter / dot marks use PointMarker. Mixing styles in one legend is allowed when a chart layers multiple marks (e.g. a bar + line dual axis).

Auto-build from a Plot

let plot = Plot::new(df)
    .mark(Mark::Bar { value_labels: false })
    .encode(plot::x("quarter", ScaleKind::Band))
    .encode(plot::y("revenue", ScaleKind::Linear))
    .encode(plot::color("region"));

let legend = plot.legend(&theme);
// Caller positions + renders the legend separately:
let legend_graphics = legend.emit_graphics(
    Vec2::new(viewport.x - 120.0, 20.0),
    viewport,
    &theme.legend,
    font.cell_pixels() as f32,
);
let _ = stage.add_child(root, legend_graphics);

let labels = legend.emit_text_labels(
    Vec2::new(viewport.x - 120.0, 20.0),
    viewport,
    &theme.legend,
    theme.text_primary,
    &font,
);
for t in labels {
    let _ = stage.add_child(root, t);
}

Orientation

OrientationWhen to use
VerticalNarrow side panels, tall charts, many categories
HorizontalAbove / below the plot area, ≤ ~6 categories, wide

Horizontal layouts wrap to a new row when the running x exceeds the viewport width.

Manual construction

Tip

Use the builder when the legend isn't 1:1 with a Plot's color encoding — e.g. annotating two reference lines on a custom chart or pulling the same legend into multiple charts.

let legend = Legend::new()
    .item("Q1", SwatchStyle::ColorBox(navy))
    .item("Q2", SwatchStyle::ColorBox(vermillion))
    .orientation(LegendOrientation::Horizontal);

Line chart

A line chart connects rows of a DataFrame in order with a stroked polyline. Multi-series support comes via a Color encoding: each distinct category becomes its own line.

Demo — US unemployment rate, 1929–1941

The demo plots annual US unemployment through the Great Depression and into early World War II. The peak (24.9 % in 1933) is the worst single year on record; the 1937–38 secondary peak is the "Roosevelt Recession" after premature monetary tightening. The chart fades in over 1.5 s with Ease::OutCubic — a subtle "the chart is rendering itself" cue rather than a hard appear.

Source: Unemployment in the United States — Wikipedia

Public surface

let plot = Plot::new(daily)
    .mark(Mark::Line {
        interpolation: Interpolation::Linear,
        marker: Some(PointStyle::Circle),
    })
    .encode(plot::x("date", ScaleKind::Band))
    .encode(plot::y("value", ScaleKind::Linear))
    .encode(plot::color("metric"));

Mark variants

VariantWhen to use
Mark::Line { Linear, None }Standard time-series / continuous-x lines
Mark::Line { Step, None }Monotonic step series (quarterly milestones, billing tiers)
Mark::Line { *, Some(Circle) }Sparse data — readers spot individual points

Interpolation

Info

Interpolation::Linear connects (x₁, y₁) → (x₂, y₂) directly. Interpolation::Step inserts an L-shaped joint at (x₂, y₁), producing horizontal-then-vertical segments. Each step segment doubles the primitive count vs Linear.

Multi-series via Color encoding

When a Color encoding is present, the renderer splits rows by the color column's value and emits one polyline per series. Each series picks a palette colour from the theme. Use Plot::legend to auto-build a matching legend.

Theme integration

Theme fieldDrives
theme.plot.line_width_pxStroke thickness of every line
theme.plot.line_marker_radius_pxRadius of PointStyle::Circle markers
theme.palettePer-series stroke colour

Coordinate convention

Warning

For the Band X scale (categorical x), each row's x position is the centre of its band. For continuous X (Linear scale on a numeric column), the x position is the scale's map(value). The fundamental NDC flip is the same as bars — see Axes for the coordinate-convention note.

Grouped bar chart

Side-by-side comparison of 2–5 series within each X-band — e.g. revenue per region per quarter. Each outer band (a quarter) is subdivided into one inner band per series (a region).

Demo — Apollo crewed missions per year, 1968–1972

The plain-bar variant (?chart=bar) below plots crewed Apollo missions flown per calendar year. 1969 carries four flights (Apollo 9 LM rehearsal, Apollo 10 lunar reconnaissance, Apollo 11 first landing, Apollo 12 second landing) — the program's single most ambitious year. By 1972 only Apollo 17 flew.

Source: Apollo program — Wikipedia

Grouped variant — Apollo annual budget by NASA centre

The same Apollo dataset, sliced differently. Annual program outlays ($B, 1973 dollars) for five reference years (1962, 1964, 1966, 1969, 1972), grouped by the three centres that owned the spend: Marshall (Saturn V), Manned Spacecraft Center (CSM + LM), and Kennedy (launch ops + tracking). The 1966 peak matches the late-stage Saturn V flight-hardware build right before Apollo 4's all-up test.

Source: Apollo program — Costs — Wikipedia

Public surface

The grouped layout is one extra encoding on top of the standard bar chart:

let plot = Plot::new(rows)
    .mark(Mark::Bar { value_labels: false })
    .encode(plot::x("quarter", ScaleKind::Band))
    .encode(plot::y("revenue", ScaleKind::Linear))
    .encode(plot::color("region"))
    .encode(plot::x_offset("region"));

Adding plot::x_offset(field) re-bands the X axis: each unique value of field becomes a sub-band within the outer X band.

Layout

Info

The outer X band (e.g. "Q1") spans some pixel range [bx0, bx1]. The grouped layout constructs an inner BandScale over the distinct XOffset categories with that range as its pixel range and a 10% inner padding, then asks for the sub-band's [ix0, ix1].

Pairing with Legend

When the same column drives both Color and XOffset, Plot::legend returns a legend whose colours match the bar segments exactly. Use the legend chapter for placement.

Theme integration

Grouped bars reuse the bar palette + theme. Inner band padding is fixed at 0.10 today; an explicit PlotTheme.bar_inner_padding field lands when the value needs to be customisable.

Stacked bar chart

Stacked bars show composition within a category — revenue per region as fractions of the quarterly total. Normalized mode divides each segment by its band's total, producing 100%-stacked bars where every band reaches the plot top.

The demo plots the Apollo program's annual outlay by NASA centre, 1962–1972 ($B in 1973 dollars). Same dataset the grouped-bar chapter shows side-by-side, but stacked: total program spend per year reads off the top of each bar, and the Marshall (Saturn V) slice growing through 1966 before tapering tells the program-arc story in one chart.

Source: Apollo program — Costs — Wikipedia

Public surface

use wisp_chart::plot::Transform;

let plot = Plot::new(rows)
    .mark(Mark::Bar { value_labels: false })
    .encode(plot::x("quarter", ScaleKind::Band))
    .encode(plot::y("revenue", ScaleKind::Linear))
    .encode(plot::color("region"))
    .transform(Transform::Stack { normalize: false });

Two modes from one transform

Transform::Stack { normalize }Behaviour
falseCumulative absolute values — Q1 stack hits Q1 total
trueEach band rescaled to the y-domain top — every bar reaches 100%

Info

The renderer walks rows in DataFrame order, accumulating a per-band cumulative offset. The first row for a band sits at the baseline; subsequent rows stack on top. The series colour comes from the Color encoding's palette lookup.

Pairing with Legend

Use Plot::legend to auto-emit a legend that maps colours to series — same shape as grouped bars, and the legend's swatches use the same palette positions as the stacked segments.

Stack + XOffset = grouped-stacked

Combining Transform::Stack with Encoding::XOffset is allowed: each outer X band is sub-banded by XOffset, and within each sub-band the rows stack by Color. Useful for "stacked by component within grouped by quarter" layouts.

Scatterplot

Two continuous numeric variables plotted as points — correlation explorations, A/B comparisons, sample distributions. Categorical colour and varying size for richer reads.

The demo plots Fisher's Iris (1936): petal length × petal width across the three species setosa, versicolor, and virginica. R.A. Fisher published this dataset in The Use of Multiple Measurements in Taxonomic Problems to demonstrate linear discriminant analysis; it has since anchored statistical classification, machine-learning tutorials, and 90 years of shape-from-petal arguments.

Source: Iris flower data set — Wikipedia

Public surface

let plot = Plot::new(samples)
    .mark(Mark::Point { shape: PointShape::Circle })
    .encode(plot::x("height", ScaleKind::Linear))
    .encode(plot::y("weight", ScaleKind::Linear))
    .encode(plot::color("species"))
    .encode(plot::size("age"));

Point shapes

ShapePrimitive used
CircleGraphics::draw_ellipse
SquareGraphics::draw_rect
DiamondGraphics::draw_polygon (4 verts)
TriangleGraphics::draw_polygon (3 verts)
PlusTwo crossed draw_rect calls

Info

Both X and Y must use ScaleKind::Linear — scatter requires continuous numeric axes. Categorical X is the domain of bar charts.

Size encoding

Adding plot::size(field) maps a numeric column to marker radius via LinearScale mapped into (3.0, 18.0) pixel range. Use sparingly — too many sizes overlap and obscure the distribution shape.

Theme integration

Theme fieldDrives
theme.plot.line_marker_radius_pxDefault marker radius (no Size)
theme.palettePer-category fill colour

Bubble chart

A bubble chart is a scatterplot with a third magnitude encoded as marker size — the canonical multi-channel "ah ha" plot.

The demo plots Gapminder 2007: GDP per capita (PPP) × life expectancy × population across 13 countries, coloured by continent. Hans Rosling made this dataset famous in his 2006 TED talk "The best stats you've ever seen", animating the same encoding from 1800 → present to show every country's simultaneous arc through rising income + rising lifespan.

Source: Gapminder Foundation — Wikipedia

Public surface

let plot = Plot::new(countries)
    .mark(Mark::Point { shape: PointShape::Circle })
    .encode(plot::x("gdp", ScaleKind::Log))
    .encode(plot::y("life_expectancy", ScaleKind::Linear))
    .encode(plot::size("population").size_mapping(SizeMapping::Area))
    .encode(plot::color("continent"));

Area vs radius mapping

Important

Always use SizeMapping::Area for magnitude data. Radius mapping is visually misleading: a 4× value renders 16× larger because area = πr². Area mapping preserves the perceptual link between value and visible bubble size.

SizeMappingBehaviourWhen to use
Area (default)sqrt(scaled value) → radius. 4× value → 4× visible bubble.Magnitudes, populations, totals
RadiusScaled value → radius directly.When the value is already a length/distance

Multi-encoding read

A typical bubble chart uses 4 channels at once: X, Y, Size, Color. Combined with Plot::legend, the reader sees the Color legend automatically; future tickets will add an explicit Size legend overlay for the third dimension.

Area chart

A line chart whose region between the curve and the baseline is filled. Use for magnitude-over-time visualisations where the area under the curve carries meaning.

Demo — NASA's share of the US federal budget, 1962–1972

The demo plots NASA spending as a percentage of the US federal budget through the Apollo era. The 1966 peak (~4.4 %) and the sharp post-Apollo wind-down to <2 % by 1972 tell the rise-and-fall of the moon program in one shape. Reveal eases in over 1.5 s with Ease::OutCubic.

Source: Budget of NASA — Wikipedia

Public surface

let plot = Plot::new(daily)
    .mark(Mark::Area { interpolation: Interpolation::Linear })
    .encode(plot::x("date", ScaleKind::Band))
    .encode(plot::y("value", ScaleKind::Linear))
    .encode(plot::color("region"));

Convex-quad-per-segment emission

Info

wisp's draw_polygon is convex-only in v1 — it fan-triangulates from vertex 0, which produces overlapping triangles when the input polygon is non-convex. A typical area chart polygon (line + baseline) is non-convex whenever the line bends.

The renderer sidesteps this by emitting one convex quadrilateral per segment: (x0, baseline) → (x1, baseline) → (x1, y1) → (x0, y0). Each quad is always convex, so the fan triangulation is correct. Visually identical to one big polygon; costs one extra primitive per segment.

Interpolation modes

ModeQuad shape
LinearSlanted top edge connecting (x0, y0) → (x1, y1)
StepFlat top edge at y0 from x0 to x1 (step bar)

Pairing with Color encoding

A Color encoding splits rows into one series per category and emits one polygon stream per series. Without an explicit back-to-front render order today, overlapping areas can occlude each other; future tickets will add z-ordering by series area.

Connected scatterplot

A scatterplot whose points are joined by a line in a meaningful sequence — usually time. The reader sees the trajectory through 2D space, not just where points cluster.

The demo plots the US Phillips curve, 1960 → 1980, with annual (inflation, unemployment) pairs joined in chronological order. The 1960s sit in the lower-left in the classic downward trade-off; the 1970s stagflation shock punches the line out toward the upper-right (both axes climbing together) — the empirical observation that broke Keynesian consensus and powered Milton Friedman's natural-rate theory.

Source: Phillips curve — Wikipedia

Public surface

let plot = Plot::new(quarterly)
    .mark(Mark::Line {
        interpolation: Interpolation::Linear,
        marker: Some(PointStyle::Circle),
    })
    .encode(plot::x("inflation", ScaleKind::Linear))
    .encode(plot::y("unemployment", ScaleKind::Linear))
    .encode(plot::order("quarter_index"))
    .encode(plot::color("decade"));

The minimum recipe: a line mark with markers on, X and Y both Linear, and an order encoding that names the sort column.

Order encoding

Info

Encoding::Order sorts each series's rows by the named numeric column before line-segment emission. Without it, rows are connected in DataFrame insertion order — fine for already- sorted time series, wrong for shuffled input. Always set it when your reader expects the line to follow time.

Continuous-X line vs band-X line

The Plot facade detects the X scale kind and routes:

X ScaleKindLayoutUse case
Linear / Log / TimeContinuous numeric axesConnected scatter, time-series
Band (default)Categorical bands at centresStandard line chart, e.g. quarterly

Same Mark::Line mark; different X scale picks the right projection automatically.

KPI / indicator card

A dashboard summary tile: one big number, a one-line label, a colour-coded delta, optional sparkline.

The demo reports Apollo 11 lunar surface samples returned — 47.5 lb (21.6 kg), 2.5 lb under the mission's 50 lb pre-flight goal. The sparkline traces all six Apollo landings: 11 / 12 / 14 / 15 / 16 / 17. The visible "hockey stick" from sample 4 onwards is the Lunar Roving Vehicle arriving with Apollo 15 in 1971 — once astronauts could traverse kilometres instead of a hundred metres, sample mass quintupled.

Source: Moon rock — Wikipedia

Public surface

use wisp_chart::indicator::{Kpi, Delta, DeltaKind};

let kpi = Kpi {
    value: 1_234_567.0,
    label: "Monthly Active Users".into(),
    delta: Some(Delta {
        kind: DeltaKind::Up,
        formatted: "+12.4% vs last mo".into(),
    }),
    sparkline: Some(vec![100.0, 105.0, 102.0, 110.0, 115.0]),
};

// Sparkline as Graphics:
let sparkline = kpi.emit_graphics(&theme, Vec2::new(240.0, 120.0));
let _ = stage.add_child(root, sparkline);

// Big number + label + delta as Text:
let font = Font::bitmap_8x8(&app);
for t in kpi.emit_text_labels(&theme, Vec2::new(240.0, 120.0), &font) {
    let _ = stage.add_child(root, t);
}

Numeric formatting

format_value compacts large magnitudes into the closest power-of-thousand abbreviation:

ValueRendered
2_500_000_0002.50B
1_234_5671.23M
45_67845.7K
789789
1.51.50

Override by formatting your value into a string and storing it in Delta.formatted if you need locale-specific formatting.

Delta colours

Info

DeltaKind::Up reads theme.indicator.delta_up (green by default), Down reads delta_down (red), Neutral reads delta_neutral (grey). The arrow glyph is ^ / v / - in v1 — replace with proper Unicode arrows once the bitmap atlas covers them.

Layout

The card is laid out within a viewport_px rectangle:

RegionPosition
Valuey = 12 px, left-aligned 8 px padding
LabelBelow value with 8 px gap
DeltaBelow label with 8 px gap
SparklineBottom 25% of viewport, 8 px horizontal padding

Gauge chart

A semicircular gauge — value vs target with colour-coded threshold zones and a needle indicator. The default ops/SLA dashboard chart.

The demo reports Apollo 11's Command Module cabin pressure during the trans-lunar coast — ≈ 5.0 psi of pure O₂. NASA adopted the 5-psi standard after the Apollo 1 fire (Jan 1967) ruled out the original 14.7-psi atmosphere; everything above this nominal sits in the orange "caution" band, with the red fault zone at 8 psi.

Source: Apollo 1 — Wikipedia

Public surface

use wisp_chart::indicator::{Gauge, Zone};
use wisp_chart::color::Color;

let gauge = Gauge {
    value: 73.0,
    domain: (0.0, 100.0),
    zones: vec![
        Zone::new((0.0, 60.0),   Color::from_hex("#27ae60").unwrap()),
        Zone::new((60.0, 85.0),  Color::from_hex("#f5a623").unwrap()),
        Zone::new((85.0, 100.0), Color::from_hex("#e74c3c").unwrap()),
    ],
};

let g = gauge.emit_graphics(&theme, Vec2::new(240.0, 160.0));
let _ = stage.add_child(root, g);

let labels = gauge.emit_text_labels(&theme, Vec2::new(240.0, 160.0), &font);
for t in labels { let _ = stage.add_child(root, t); }

Angle convention

Info

Domain min maps to angle π (left, 180°). Domain max maps to angle 0 (right, 0°). Mid-domain is π/2 (top). Values outside the domain are clamped before angle conversion.

Value (% of domain)Arc anglePosition
0%π (180°)Left edge
25%3π/4Upper-left
50%π/2 (90°)Top
75%π/4Upper-right
100%0Right edge

Layout primitives

The renderer composes:

  1. Track — a neutral-grey full semicircle annular sector.
  2. Zones — one annular sector per zone, painted in order (later zones overlap earlier ones).
  3. Needle — a thin radial line from the gauge centre to the value's angle on the outer radius.
  4. Hub — a small filled circle at the pivot for visual completion.

All four use primitives that landed in AUT-224's arc support — no new geometry needed in wisp.

Theme integration

FieldDrives
theme.indicator.gauge_track_width_pxBand thickness
theme.indicator.gauge_needle_colorNeedle + hub colour
theme.indicator.numeric_font_sizeCentred value display
theme.plot.gridline_minor.colorTrack background colour

Bullet chart

Stephen Few's compact performance-vs-target chart. A horizontal (or vertical) bar with three qualitative ranges painted behind it, a target marker line, and the current value as a thinner foreground bar.

The demo reports the 2005 DARPA Grand Challenge — Stanford's "Stanley" drove 132.2 mi across the Mojave (target: 132 mi) in 6 h 53 m to take the $2 M prize. The 2004 Challenge's best result was 7.4 mi; one year later five robots finished. The visible band just inside the target marker is the moment self- driving cars stepped off the slide deck.

Source: DARPA Grand Challenge (2005) — Wikipedia

Public surface

use wisp_chart::indicator::{Bullet, Orientation};

let bullet = Bullet {
    value: 270.0,
    target: 250.0,
    ranges: [150.0, 225.0, 300.0], // poor → ok → good thresholds
    orientation: Orientation::Horizontal,
};

let g = bullet.emit_graphics(&theme, Vec2::new(400.0, 80.0));
let _ = stage.add_child(root, g);

The five primitives

Info

A bullet chart renders five primitives in this order:

  1. Poor band — [0, ranges[0]], light grey
  2. OK band — [0, ranges[1]], medium grey (paints over poor)
  3. Good band — [0, ranges[2]], darker grey (paints over OK)
  4. Value bar — thinner foreground bar from 0 to value
  5. Target line — vertical (or horizontal) marker at target

Bands paint over each other because each spans from 0 to its threshold. The visible bands are the differences: poor is the portion before ranges[0], OK is the strip from ranges[0] to ranges[1], good is the strip from ranges[1] to ranges[2].

Orientation

OrientationUse case
Horizontal (default)Dashboard rows, tight vertical packing
VerticalSidebar KPIs, when label fits to the left

Theme integration

FieldDrives
theme.indicator.bullet_poor_colorPoor (lowest) qualitative band
theme.indicator.bullet_ok_colorSatisfactory (middle) band
theme.indicator.bullet_good_colorGood (highest) band
theme.indicator.bullet_value_colorValue bar fill
theme.indicator.bullet_target_colorTarget marker line

Why bullet vs gauge

Tip

For one-shot dashboard tiles where vertical space is tight, bullet wins — it fits inside a row that already has a label and delta, while a gauge needs its own square aspect. Save gauges for the one hero metric that justifies the footprint.

Pie / donut chart

Categorical proportions of a whole — budget allocation, market share, traffic-source mix. Donut variant adds a centred hole.

Demo — Nightingale's mortality data, Crimean War 1854–55

The demo plots causes of British army mortality during the first winter of the Crimean War (April 1854 – March 1855): 83 % preventable disease, 8 % battle wounds, 9 % other. This is the dataset that became Florence Nightingale's famous polar-area "coxcomb" diagram — the chart that drove sanitary reform across military hospitals and made the case for hygiene-as-public-health.

Source: Florence Nightingale — Wikipedia

Public surface

use wisp_chart::polar::{Pie, Slice};
use wisp_chart::color::Color;

let pie = Pie::new(vec![
    Slice::new(45.0, "Organic",  Color::from_hex("#0072b2").unwrap()),
    Slice::new(25.0, "Paid",     Color::from_hex("#d55e00").unwrap()),
    Slice::new(15.0, "Social",   Color::from_hex("#009e73").unwrap()),
    Slice::new(10.0, "Referral", Color::from_hex("#cc79a7").unwrap()),
    Slice::new(5.0,  "Direct",   Color::from_hex("#f0e442").unwrap()),
]);
let g = pie.emit_graphics(&theme, Vec2::new(320.0, 320.0));

Donut variant

let donut = Pie::new(slices).hollow_ratio(0.5);

hollow_ratio is clamped to [0, 0.95]. 0.0 = pie. Common donut hole values are 0.40.6 (typical brand donuts) or 0.7+ (thin ring).

Layout

Info

Slices render in input order, winding CCW from the +x axis (3 o'clock). Each slice's angular span is value / total * 2π. The pie centre sits at the centre of viewport_px; outer radius is min(width, height) * 0.45.

Caveats

Warning

Pie charts are perceptually unreliable for comparing slices that aren't dramatically different in size — readers can't estimate angle ratios well. Prefer a bar chart when the ranking of values matters.

Sunburst chart

Radial hierarchical layout — root at the centre, each depth radiates outward as a concentric ring. Child segments span the angular range of their parent.

The demo decomposes the Apollo program's $25.4 B lifetime cost (1973 dollars) by NASA centre and then by program element. The inner ring groups the four big centres (Marshall → Saturn V, Manned Spacecraft Center → CSM + LM, Kennedy → launch + tracking, HQ → R&D + ops); the outer ring breaks each group into its program-element line items. Marshall's Saturn V slice alone is 36 % of the whole pie — the most expensive single machine ever built.

Source: Apollo program — Costs — Wikipedia

Public surface

use wisp_chart::polar::{Sunburst, SunburstNode};
use wisp_chart::color::Color;

let c = |hex| Color::from_hex(hex).unwrap();
let s = Sunburst::new(SunburstNode::group("root", c("#888"), vec![
    SunburstNode::group("Sales", c("#0072b2"), vec![
        SunburstNode::leaf("NA",   30.0, c("#56b4e9")),
        SunburstNode::leaf("EU",   20.0, c("#7faedc")),
        SunburstNode::leaf("APAC", 15.0, c("#a3c7ea")),
    ]),
    SunburstNode::group("Eng", c("#009e73"), vec![
        SunburstNode::leaf("Platform", 25.0, c("#3eb893")),
        SunburstNode::leaf("App",      20.0, c("#71cba8")),
    ]),
]))
.ring_width_px(30.0);
let g = s.emit_graphics(&theme, Vec2::new(320.0, 320.0));

Layout

Info

Depth 0 (the root) is NOT drawn — only its descendants. Depth 1 is the inner ring. Each child's angular span is child_weight / parent_weight * parent_span. Leaf weights are the supplied value; internal-node weights are computed as the sum of descendant leaves.

When sunburst beats treemap

Tip

Sunburst is the right call when depth matters more than area precision. Reading a 4-level hierarchy is easier in concentric rings than in nested rectangles because the rings give visual isolation. Use treemap when leaf-area proportions are the primary read.

Radar / spider chart

Multi-axis polygon overlay for multivariate comparison. Each entity becomes one polygon; vertices land on the per-axis value projected onto a polar coord system.

The demo plots the 1960 Rome Olympics medal table: USA vs USSR across five categories (gold / silver / bronze / track-and- field medals / total). 1960 was the first Summer Games the USSR topped the table at — the kickoff of the Cold War medal rivalry that ran through the 1988 Seoul boycott. The visible gap on the Total spoke shows the USSR's 32-medal margin.

Source: 1960 Summer Olympics medal table — Wikipedia

Public surface

use wisp_chart::polar::{Radar, RadarAxis, RadarSeries};
use wisp_chart::color::Color;

let r = Radar::new(
    vec![
        RadarAxis::new("speed",       (0.0, 100.0)),
        RadarAxis::new("range",       (0.0, 100.0)),
        RadarAxis::new("comfort",     (0.0, 100.0)),
        RadarAxis::new("efficiency",  (0.0, 100.0)),
        RadarAxis::new("price",       (0.0, 100.0)),
    ],
    vec![
        RadarSeries::new("Model A", vec![80.0, 70.0, 60.0, 90.0, 50.0], Color::from_hex("#0072b2").unwrap()),
        RadarSeries::new("Model B", vec![60.0, 85.0, 80.0, 70.0, 75.0], Color::from_hex("#d55e00").unwrap()),
    ],
);

Layout

Info

Axes are placed at evenly-spaced angles starting at the top (+π/2), winding CCW. Each axis has its own (min, max) domain so heterogeneous units can share the chart. Concentric polygon gridlines at 25%, 50%, 75%, 100% reference the scale.

Convex caveat

Warning

wisp's draw_polygon is convex-only in v1 (fan triangulation). Radar polygons are always star-convex from the centre, so the fan from vertex 0 still renders correctly. If you generate the input vertices another way (custom order, non-star-shaped), expect visible triangle artifacts.

Best uses

Tip

Radar reads best for 3–6 axes and 2–4 series. Beyond that the polygon overlap turns into visual noise. For high-dimensional comparisons use a parallel-coordinates plot instead.

Polar coordinate plot

Charts where angle has meaning — wind direction, compass bearing, time-of-day distributions. v1 ships a wind-rose-style polar bar variant: concentric grid + radial spokes + one filled sector per category, sized to its value.

The demo plots Florence Nightingale's monthly disease deaths in the British Army during the Crimean War, April–November 1854 — the per-month breakdown behind her famous coxcomb diagram in Notes on Matters Affecting the Health, Efficiency, and Hospital Administration of the British Army (1858). The pie chapter shows the aggregate three-category summary; this polar view shows the same data sliced by month — the Sep–Nov peak driven by the Scutari hospital sanitation crisis stands out immediately.

Source: Florence Nightingale — Wikipedia

Public surface

use wisp_chart::polar::{PolarPlot, PolarCoord};
use wisp_chart::color::Color;

let plot = PolarPlot::new(
    vec!["N".into(), "NE".into(), "E".into(),  "SE".into(),
         "S".into(), "SW".into(), "W".into(),  "NW".into()],
    vec![12.0, 18.0, 22.0, 30.0, 25.0, 16.0, 14.0, 8.0],
);
let g = plot.emit_graphics(&theme, Vec2::new(320.0, 320.0));

Coord convention

Info

The polar coord system is exposed via [PolarCoord] for callers who want to compose their own primitives on a polar layout:

Angle (rad)Direction
0Right (+x)
π/2Top (-y screen)
πLeft (-x)
3π/2Bottom (+y screen)

PolarCoord::to_pixel(θ, r ∈ [0, 1]) projects to pixel space: (centre.x + r·cos θ, centre.y − r·sin θ). Screen +y is down, so sin(θ) is negated to keep "0 = right, π/2 = top" reading.

Sector layout

Note

Sectors start at angle π/2 (top — N on a compass) and go clockwise through the category list. This matches compass convention: N → NE → E → … → NW → back to N.

Beyond wind roses

Tip

For richer polar marks (lines, points, custom sectors), use PolarCoord directly to convert your data to pixel positions and emit wisp::Graphics primitives. The PolarPlot value type is a ready-baked wind-rose; the coord system underneath is general.

For specific polar shapes that have their own constructors and chapter — read them first if your use case fits:

  • Pie / donut — categorical proportions filling 2π.
  • Sunburst — radial hierarchy across rings.
  • Radar — multi-axis polygon overlay.
  • Gauge — semicircle indicator with threshold zones.

Candlestick chart

OHLC price per period rendered as a body (open → close) with a thin wick spanning the period high → low.

The demo plots the Dow Jones Industrial Average around the Wall Street Crash of 1929 — eight trading days from Mon Oct 21 through Wed Oct 30 1929, spanning Black Thursday (Oct 24), Black Monday (Oct 28), and Black Tuesday (Oct 29). The Oct 28 candle's body is the day the Dow lost 13 % in a single session; Oct 29 cut another 12 %, then a partial recovery on the 30th set the tone for the Great Depression to come.

Source: Wall Street Crash of 1929 — Wikipedia

Public surface

use wisp_chart::finance::{Candlestick, Period};

let c = Candlestick::new(vec![
    Period::new(100.0, 110.0, 95.0, 108.0),
    Period::new(108.0, 115.0, 105.0, 102.0),
    /* ... */
]);
let g = c.emit_graphics(&theme, Vec2::new(480.0, 240.0));

Info

Up periods (close >= open) use up_color (green default); down periods use down_color (red default). Override before emission for brand colours.

Why not a Plot mark

Note

Period { open, high, low, close } doesn't fit the (X, Y, Color) channel model of the Plot facade — it's 4 numeric fields per row. Candlestick / OHLC / waterfall ship as self-contained value types under wisp_chart::finance instead.

OHLC bar chart

Pre-candlestick OHLC visualisation. A thin vertical line for the period range with two small horizontal ticks — left = open, right = close. More compact than candles for dense charts; preferred by some traders.

The demo plots the same dataset as the candlestick chapter: the Dow Jones across the Wall Street Crash of 1929 (Oct 21–30, including the three Black days). The OHLC encoding makes the open / close ticks easier to compare across consecutive sessions when range spans are large.

Source: Wall Street Crash of 1929 — Wikipedia

Public surface

use wisp_chart::finance::{Ohlc, Period};

let o = Ohlc::new(vec![
    Period::new(100.0, 110.0, 95.0, 108.0),
    /* ... */
]);
let g = o.emit_graphics(&theme, Vec2::new(480.0, 240.0));

Tick length is tick_length_fraction × band_width (default 0.3).

Waterfall chart

Show how a starting value evolves through a sequence of positive / negative contributions to a final value — revenue waterfall (revenue → costs → margin), budget decomposition, P&L bridges.

The demo decomposes the Apollo program's $25.4 B lifetime cost (1973 closeout, in 1973 dollars) by the four big spending buckets: the Saturn V rocket family, the Apollo CSM + Lunar Module spacecraft, ground operations + tracking, and the rest of the R&D portfolio. Saturn V alone — Wernher von Braun's moon rocket — was 36 % of the program total.

Source: Apollo program — Costs — Wikipedia

Public surface

use wisp_chart::finance::{Waterfall, WaterfallRow};

let w = Waterfall::new(vec![
    WaterfallRow::summary("Start",   100.0),
    WaterfallRow::contribution("Revenue",  80.0),
    WaterfallRow::contribution("COGS",    -30.0),
    WaterfallRow::contribution("Opex",    -25.0),
    WaterfallRow::contribution("Tax",     -10.0),
    WaterfallRow::summary("End",     115.0),
]);

Row kinds

ConstructorVisual
WaterfallRow::summary(label, v)Full-height bar from 0 to v — typically Start / End totals
WaterfallRow::contribution(label, d)Floating bar sitting on the running total, length |d|, coloured by sign

Info

Positive contributions use positive_color (green default). Negative contributions use negative_color (red default). Summary bars use summary_color (blue default).

Baseline chart

Area chart split by a horizontal reference line — fill above the baseline in one colour (profit), below in another (loss). Common for diff series, deviation-from-target, gains-vs-losses.

The demo plots the US Federal Funds Rate, 1965–1985, baselined against the long-run 5 % anchor most macro texts use when discussing the Volcker disinflation. The visible spikes above the baseline track the late-60s Vietnam-era overheating, the post-oil-shock inflation, and the Volcker shock of 1979–82 that crushed double-digit inflation by taking the funds rate above 16 % — a peak you can read straight off the 1981 fill.

Source: Federal funds rate — Wikipedia

Public surface

use wisp_chart::baseline::BaselineChart;

let bc = BaselineChart::new(
    vec![(0.0, 10.0), (1.0, 25.0), (2.0, -10.0), (3.0, 15.0)],
    0.0, // baseline y-value
);
let g = bc.emit_graphics(&theme, Vec2::new(480.0, 240.0));

Per-segment colouring

Info

Each segment between consecutive points is coloured by the sign of the average y of its endpoints relative to the baseline. Segments that cross the baseline currently render with the average-side colour — a future refinement could split the segment at the crossing point for exact colouring.

Table heatmap

Show a 2D matrix of values — confusion matrices, hour×day activity, regional×product sales. Colour intensity replaces explicit numeric labels for fast pattern recognition.

The demo plots weekly excess-mortality rate (per 1 000) during the 1918 influenza pandemic across five US cities × eight weeks (late Sep – mid-Nov). Cities that imposed early NPIs — school closures, public-gathering bans — cap visibly lower than cities that delayed; Philadelphia's catastrophic week-4 peak followed its decision to allow a 200 000-person Liberty Loan parade on Sep 28. The data anchors Markel et al.'s 2007 JAMA analysis of NPI effectiveness.

Source: 1918 flu pandemic in the United States — Wikipedia

Public surface

use wisp_chart::heatmap::{TableHeatmap, SequentialPalette};

let h = TableHeatmap::new(
    vec!["Mon".into(), "Tue".into(), "Wed".into()],
    vec!["00h".into(), "06h".into(), "12h".into(), "18h".into()],
    vec![
        vec![ 5.0, 20.0, 40.0, 18.0],
        vec![ 8.0, 25.0, 50.0, 22.0],
        vec![10.0, 30.0, 55.0, 24.0],
    ],
).palette(SequentialPalette::blues());
let g = h.emit_graphics(&theme, Vec2::new(400.0, 240.0));

Palette options

PaletteUse case
SequentialPalette::blues()Default — single-hue magnitude
SequentialPalette::magma()Heat / intensity reads
SequentialPalette::github()Discrete-level contribution graphs
SequentialPalette::new(stops)Custom palette from your colours

Info

Each cell's colour is palette.sample((value - lo) / (hi - lo)) where (lo, hi) is the matrix's numeric extent. Linear interpolation between adjacent palette stops.

Calendar / annual heatmap

GitHub-style contribution graph or year-in-review heatmap — one cell per day, 7 rows × 53 columns, colour intensity reflects daily value.

The demo plots weekly excess-mortality from influenza + pneumonia in NYC across 1918 — the year the Spanish flu killed more Americans than World War I, World War II, the Korean War, and the Vietnam War combined. The lighter March / April band is the "first wave"; the saturated October cluster is the catastrophic autumn second wave that peaked at ~13 weekly deaths per 10 k population.

Source: 1918 flu pandemic in the United States — Wikipedia

Public surface

use wisp_chart::heatmap::{CalendarHeatmap, CalendarValue, SequentialPalette};
use jiff::civil::date;

let cal = CalendarHeatmap::new(2025, vec![
    CalendarValue::new(date(2025, 1, 15),  5.0),
    CalendarValue::new(date(2025, 6, 1),  12.0),
    /* ... */
])
.palette(SequentialPalette::github());
let g = cal.emit_graphics(&theme, Vec2::new(720.0, 120.0));

Layout

Info

Columns are ISO weeks (1..52, occasionally 53 for leap years). Rows are weekdays — Monday at row 0, Sunday at row 6. Days with no entry render at the palette's 0.0 stop. Values from other years are silently ignored so reusing a multi-year dataset across multiple heatmaps is safe.

Date math via jiff

Note

wisp-chart depends on jiff for date / weekday / ISO-week math. jiff is scoped to this crate's Cargo.toml so wisp itself stays date-free.

Lasagna plot

Pack many entity-time series into a single "lasagna" of horizontal heatmap rows — one entity per row, time across columns, colour shows value. Reads patterns across hundreds of entities that a multi-line chart spaghettis up.

The demo plots US polio incidence per 100 k population by state × half-year, 1952 → 1956. The Salk inactivated polio vaccine was approved 12 Apr 1955 and rolled out nationally that spring; every state's incidence collapses to near-zero in the two columns after the vaccine launch. The "before / after" contrast is the visual evidence that anchored the global eradication effort.

Source: Polio vaccine — Wikipedia

Public surface

use wisp_chart::heatmap::{LasagnaHeatmap, SequentialPalette};

let l = LasagnaHeatmap::new(
    vec!["entity-1".into(), "entity-2".into(), /* ... */],
    (0..24).map(|h| format!("{h:02}h")).collect(),
    vec![
        vec![/* 24 values */],
        vec![/* ... */],
    ],
).palette(SequentialPalette::magma());

Tip

Cells render flush — no gap — which is what makes a lasagna "lasagna" rather than a table heatmap. The continuous stripe lets the eye scan an entity's trajectory across time without inter-cell scaffolding.

2D histogram (binned heatmap)

Bin a 2D point cloud into a fixed grid and emit one filled cell per bin, coloured by count. Useful when a scatterplot over-plots so heavily that individual points stop being legible.

The demo plots the Hertzsprung–Russell diagram — stellar effective temperature (log K, X) against absolute magnitude (M_V, Y, brighter up) for ~640 synthesised stars. The dense diagonal running top-right → bottom-left is the main sequence; the cluster top-left is white dwarfs; the upper-right scatter is red giants + supergiants. The chart was published independently by Hertzsprung (1911) and Russell (1913) and is the single most important diagram in stellar astrophysics.

Source: Hertzsprung–Russell diagram — Wikipedia

Public surface

use wisp_chart::heatmap::Histogram2D;

let points: Vec<(f32, f32)> = collect_xy_observations();
let h2 = Histogram2D::from_points(
    &points,
    /* cols */ 24,
    /* rows */ 24,
    Some(((-5.0, 5.0), (-5.0, 5.0))),  // optional clipping extent
);
let g = h2.emit_graphics(&theme, viewport);

Histogram2D vs. scatter

Choose 2D-histogram when:

  • N ≳ 10k points (over-plotted scatter).
  • You want density-readable colour-encoding rather than shape-encoding.
  • Outliers are less important than the bulk distribution.

Choose scatter when individual points matter (small N, outlier hunting, labelled-point overlays).

Treemap

Show hierarchical proportions by area — directory sizes, budget breakdown, taxonomic counts. Each node is a rectangle sized to its value; children pack inside their parent's rectangle.

The demo squarifies the Apollo program $25.4 B cost hierarchy — same data as the sunburst chart, different geometry. The Saturn V leaf claims the largest rectangle in the plot; CSM + LM share the second-biggest group; ground ops and R&D fill the remainder. The treemap's strength is that area ratios are directly comparable across nesting levels — the Saturn V's rectangle is genuinely 5× the size of the LM's because $9.3 B is 5× $3.0 B.

Source: Apollo program — Costs — Wikipedia

Public surface

use wisp_chart::topology::{Treemap, TreemapNode};
use wisp_chart::color::Color;

let c = |hex| Color::from_hex(hex).unwrap();
let t = Treemap::new(TreemapNode::group("root", c("#888888"), vec![
    TreemapNode::group("Sales", c("#0072b2"), vec![
        TreemapNode::leaf("NA",   30.0, c("#56b4e9")),
        TreemapNode::leaf("EU",   20.0, c("#7faedc")),
    ]),
    TreemapNode::group("Eng", c("#d55e00"), vec![
        TreemapNode::leaf("Platform", 25.0, c("#e8853d")),
        TreemapNode::leaf("App",      18.0, c("#eea063")),
    ]),
]));
let g = t.emit_graphics(&theme, Vec2::new(480.0, 300.0));

Layout — slice-and-dice (v1)

Info

v1 uses slice-and-dice: even-depth nodes split vertically (rows stacked top-down), odd-depth nodes split horizontally (columns stacked left-to-right). Predictable, pixel-stable, dependency-free.

Note

Slice-and-dice produces visually weaker rectangles for wildly imbalanced trees (long thin strips) than squarify. Squarify support is a follow-on; today's layout is "good enough" for most product-data hierarchies.

Funnel chart

Staged conversion / loss visualisation. Each stage is a horizontal band; band width reflects remaining count relative to the widest stage.

The demo plots NASA's Mercury Seven astronaut selection, 1958–59: 508 military test pilots invited → 110 records- reviewed → 32 screened at the Lovelace Clinic + Wright-Patterson AFB → 18 finalists → 7 selected on 9 April 1959. The narrowest funnel in spaceflight history.

Source: Mercury Seven — Wikipedia

Public surface

use wisp_chart::topology::{Funnel, FunnelStage};
use wisp_chart::color::Color;

let c = |hex| Color::from_hex(hex).unwrap();
let f = Funnel::new(vec![
    FunnelStage::new("Visited",   10000.0, c("#0072b2")),
    FunnelStage::new("Signed up",  4000.0, c("#56b4e9")),
    FunnelStage::new("Activated",  1800.0, c("#7faedc")),
    FunnelStage::new("Converted",   600.0, c("#a3c7ea")),
]);
let g = f.emit_graphics(&theme, Vec2::new(400.0, 300.0));

Info

Each band is horizontally centred — width = count / max_count × plot_width. The drop-off between adjacent bands shows where the biggest losses happen, which is usually what the reader cares about.

Sankey flow diagram

Show flows between nodes laid out in columns — sources on the left, sinks on the right, intermediate nodes in between. Ribbon thickness encodes flow magnitude. Useful for conversion funnels, budget allocation, energy flow, attribution.

The demo plots the NASA astronaut career flow, Groups 1–3 (Mercury / Gemini / Apollo eras). ~30 astronauts; sources on the left are the service branches they came from (USAF, Navy / USMC); the middle column groups them by training cohort (Mercury or Gemini); the right shows the ultimate Apollo outcome (Walked on Moon vs Did not). Twelve astronauts ultimately walked on the Moon — the right-hand ribbon converging into that node is the visible bottom-line of the whole program.

Source: NASA Astronaut Group 1 — Wikipedia

Public surface

use wisp_chart::topology::{Sankey, SankeyLink, SankeyNode};

let nodes = vec![
    SankeyNode::new("Organic",   /* column */ 0, blue),
    SankeyNode::new("Paid",      0, orange),
    SankeyNode::new("Signed Up", 1, green),
    SankeyNode::new("Trial",     1, pink),
    SankeyNode::new("Converted", 2, sky),
    SankeyNode::new("Lost",      2, gold),
];
let links = vec![
    SankeyLink::new(0, 2, 40.0, grey),
    SankeyLink::new(0, 3, 25.0, grey),
    // ...
];
let s = Sankey::new(nodes, links);
let g = s.emit_graphics(&theme, viewport);

Layout

v1 uses a column-based layout: every node names its column explicitly, link Y-positions stack within each column in declaration order, and ribbon ribbons are drawn as convex quads (not Bezier curves). Quads are visually noisier than Beziers at crossings but are deterministic, cheap, and easy to test — appropriate for v1.

When to reach for Sankey

  • Conversion / funnel narratives where you also want to show the lost flow at each step.
  • Budget allocation between fixed buckets.
  • "Where does this come from / where does it go" — anything with a clear flow direction.

For a strictly-stepped conversion without crossings, funnel is the simpler shape.

Box plot

Five-number summary of a distribution per category — min / Q1 / median / Q3 / max. Side-by-side boxes compare distributions across categories.

The demo plots Boston Marathon men's winning times by decade (1900s through 1960s), in minutes. The marathon began in 1897 as the second-oldest marathon worldwide; each box summarises the per-decade spread of the winning time. The progression from a 158-min median in the 1900s to 138 min in the 1960s tracks training science + course modernisation; the wide 1920s box is the pre-pace-strategy era where race tactics were still being invented. Source: Wikipedia "List of Boston Marathon winners".

Source: List of Boston Marathon winners — Wikipedia

Public surface

use wisp_chart::distributions::{BoxPlot, Box};
use wisp_chart::color::Color;

// From precomputed quartiles:
let bp = BoxPlot::new(vec![
    Box::from_summary("A", 10.0, 20.0, 30.0, 45.0, 60.0, Color::from_hex("#0072b2").unwrap()),
    /* ... */
]);

// Or from raw samples:
let samples: Vec<f32> = /* … */;
let bx = Box::from_samples("ints", &samples, Color::from_hex("#d55e00").unwrap()).unwrap();

Info

from_samples uses the inclusive-method percentile lookup — samples[((n-1) × p) as usize] after sorting. Good for a quick exploration; for publication-grade quartiles compute them upstream (e.g. with statrs) and call from_summary.

Per-box primitives

Each box emits 6 primitives: 1 filled rect (Q1→Q3), 1 median line, 2 whiskers (min→Q1 + Q3→max), 2 whisker caps. A 4-box plot is 24 primitives total.

Parallel coordinates plot

Explore multivariate datasets (4–12 dimensions) by drawing one polyline per row across parallel vertical axes — each axis normalised to its own domain. Patterns (clusters, outliers, anti-correlated dimensions) emerge from polyline shapes.

The demo plots the six Apollo crewed lunar missions (11 / 12 / 14 / 15 / 16 / 17) across four mission dimensions: total mission duration (days), surface EVA hours, kilometres traversed on the Moon, and sample mass returned (kg). The visible "step up" between Apollo 14 and Apollo 15 is the Lunar Roving Vehicle's first deployment — every dimension jumps together because once astronauts could drive, every mission profile expanded.

Source: Apollo program — Lunar missions — Wikipedia

Public surface

use wisp_chart::distributions::{ParallelCoords, ParallelAxis, ParallelRow};
use wisp_chart::color::Color;

let c = |hex| Color::from_hex(hex).unwrap();
let pc = ParallelCoords::new(
    vec![
        ParallelAxis::new("mpg", (10.0, 50.0)),
        ParallelAxis::new("cyl", ( 3.0,  8.0)),
        ParallelAxis::new("hp",  (60.0, 300.0)),
        ParallelAxis::new("wt",  ( 1.5,   5.5)),
    ],
    vec![
        ParallelRow::new(vec![32.0, 4.0,  95.0, 2.2], c("#0072b2")),
        ParallelRow::new(vec![14.0, 8.0, 280.0, 4.4], c("#009e73")),
        /* ... */
    ],
);

Info

Each axis carries its own (min, max) domain so heterogeneous units share the chart cleanly. Values are clamped to [0, 1] of their axis before pixel mapping.

Visual reads

Tip

Crossing patterns: lines that cross between two adjacent axes signal negative correlation. Lines that stay parallel signal positive correlation. Clusters: bundles of lines following a common shape across most axes indicate sub-groups worth investigating with a more pointed chart (scatter, box).

Error bars overlay

Show measurement uncertainty alongside a central tendency — confidence intervals on a bar chart, standard errors on a scatter, fixed-value error bars on a measurement series. Lifts a chart from "looks confident" to "is honest about uncertainty".

The demo plots Robert Millikan's published electron-charge values from the oil-drop experiment, 1909 → 1913, with the uncertainty bands he reported each year. Millikan's 1913 figure of 4.774 × 10⁻¹⁰ statcoulomb won him the 1923 Nobel and held as the canonical value until later re-analyses. The visible year-to-year drift — bars that don't overlap one another's intervals as you'd expect them to — is the cautionary tale Richard Feynman cited in Cargo Cult Science: error bars don't include systematic bias.

Source: Oil drop experiment — Wikipedia

Public surface

use wisp_chart::overlay::{ErrorBars, ErrorPoint, ErrorKind};
use wisp_chart::plot::{self, Mark, Plot, ScaleKind};
use wisp::math::Rect;

// 1. Render the primary chart (Bar, Point, Line — anything cartesian).
let bar = Plot::new(rows)
    .mark(Mark::Bar { value_labels: false })
    .encode(plot::x("quarter", ScaleKind::Band))
    .encode(plot::y("revenue", ScaleKind::Linear));
let bar_g = bar.render(&theme, viewport);
let _ = stage.add_child(root, bar_g);

// 2. Build the error-bars overlay with one entry per primary mark.
//    `x_fraction` is the position along the plot's x extent (0..1).
let bars = ErrorBars::new(
    vec![
        ErrorPoint::symmetric(0.125, 38.0, 5.0),
        ErrorPoint::symmetric(0.375, 52.0, 7.0),
        ErrorPoint::symmetric(0.625, 47.0, 6.0),
        ErrorPoint::symmetric(0.875, 64.0, 9.0),
    ],
    (0.0, 64.0), // Y domain matching the bar chart
);

// 3. Overlay using the SAME plot rect so whiskers land on bar centres.
let plot_rect = Rect::new(60.0, 40.0, viewport.x - 80.0, viewport.y - 80.0);
let overlay = bars.emit_graphics_in_rect(&theme, viewport, plot_rect);
let _ = stage.add_child(root, overlay);

Three error kinds

Info

[ErrorKind] documents the three input flavours ErrorPoint helpers convert into the absolute (lower, upper) representation used at render time:

  • Symmetric(half_width) — standard deviation or ±h uncertainty. Helper: ErrorPoint::symmetric(x_fraction, mean, half_width).
  • Asymmetric { lower, upper } — skewed distributions, quantile intervals. Helper: ErrorPoint::asymmetric(x_fraction, mean, below, above).
  • ConfidenceInterval(half_width) — caller pre-multiplies the standard error by the z-score (1.96 × SE for a 95% CI). The enum carries the half-width; the caller does the stats.

Plot-rect alignment

Important

The most common slip-up is calling [ErrorBars::emit_graphics] (16-px pad default) when overlaying on a Plot::Bar chart, which uses a 60-px gutter + 40-px header / footer. The whiskers land off-centre. Always use [ErrorBars::emit_graphics_in_rect] with the underlying chart's plot rectangle — for a default Plot bar chart that's Rect::new(60.0, 40.0, viewport.x - 80.0, viewport.y - 80.0).

Why this is an overlay, not a mark

Note

v1 ships ErrorBars as a self-contained value type rather than a Mark::ErrorBar variant on the Plot facade. The overlay use case spans multiple mark families (bar / point / line / box) and doesn't fit cleanly into the (X, Y, Color) channel model — the per-point lower/upper are extra dimensions. A future ticket can add Plot::overlay(ErrorBars) once enough real callers settle on a common ergonomic shape.

Histogram

Bin a sample of scalar observations into equal-width buckets and emit one bar per bin. The default binning rule is the square-root rule (⌈√n⌉); pass BinCount::Fixed(n) for a fixed bin count.

The demo plots heights of Union Army recruits, c. 1864, drawn from Benjamin A. Gould's 1869 Investigations in the Military and Anthropological Statistics of American Soldiers — the largest systematic anthropometric study of the 19th century. The distribution centres on 67.8 in (~172 cm) with σ ≈ 2.5 in, the Gaussian fit Gould reported for the 25–34 age bracket, and later anchored Galton's work on regression to the mean.

Source: Anthropometric history — Wikipedia

Public surface

use wisp_chart::distributions::{BinCount, Histogram};

let samples: Vec<f32> = collect_observations();
let hist = Histogram::from_samples(
    &samples,
    BinCount::Auto,           // sqrt-rule
    Some((0.0, 100.0)),       // optional clamping extent
);
let g = hist.emit_graphics(&theme, Vec2::new(360.0, 240.0));

Binning rules

  • BinCount::Auto⌈√n⌉ bins. Cheap, robust, biased toward over-binning for very large samples.
  • BinCount::Fixed(k) — explicit bin count. Use when comparing multiple histograms side-by-side so the bars line up.

Histogram vs. KDE

A histogram shows you exactly which observations landed where — useful for outlier hunting and reading off exact counts. A KDE shows you the underlying density estimate — useful when the bin-edge choice would distort the story. They compose; some teams ship both stacked.

Density (KDE)

Kernel-density estimate over a 1D sample. Smooths the discrete histogram into a continuous density curve. Defaults to a Gaussian kernel + Silverman's rule of thumb for the bandwidth.

The demo uses the same dataset as the histogram chapter — heights of Union Army recruits, c. 1864 (Gould's 1869 Statistics of American Soldiers). The smoothed density makes it obvious that the binned heights are essentially Gaussian around 67.8 in; the bandwidth choice controls how aggressively adjacent bins blur into one another.

Source: Anthropometric history — Wikipedia

Public surface

use wisp_chart::distributions::{BandwidthRule, KdePlot};

let kde = KdePlot::new(samples)
    .bandwidth(BandwidthRule::Silverman);    // or Manual(0.5)
let g = kde.emit_graphics(&theme, Vec2::new(360.0, 240.0));

Bandwidth rules

  • BandwidthRule::Silverman1.06·σ·n^(-1/5). Robust default for unimodal data. Mildly over-smooths bimodal distributions.
  • BandwidthRule::Manual(h) — pick your own. Useful for reproducing a published figure with a known bandwidth.

Faceted density

Render one KDE per category by repeating this fixture inside the trellis / small-multiples layout. Same value type, different containing layout — the faceted-density chapter walks through it.

Faceted density

A composition pattern, not a separate value type. Render one KDE per facet (category, time window, group) inside the trellis small-multiples layout — same data shape, repeated per facet.

The single-facet demo below reuses the KDE chapter's Civil War recruit-height dataset (Gould 1869). A genuine faceted build would render one panel per recruit-age bracket (e.g. 18-21 / 22-25 / 26-29 / 30-34) so the reader could compare the distribution shape across cohorts at a glance.

Source: Anthropometric history — Wikipedia

Pattern

use wisp_chart::distributions::KdePlot;
use wisp_chart::multi::Trellis;

let facets: Vec<KdePlot> = groups
    .iter()
    .map(|group| KdePlot::new(group.samples.clone()))
    .collect();

let trellis = Trellis::new(facets, /* cols */ 3, /* gap_px */ 16.0);
let g = trellis.emit_graphics(&theme, viewport);

Why faceted instead of overlaid

Overlaying many KDEs on one axis is legible up to ~5 series. Past that, the eye loses which curve is which even with distinct colours. Faceting trades axis-comparison ease for "each facet reads cleanly" — the right choice for >5 groups.

Picking facet count

If n series fit in cols × ⌈n/cols⌉ facets with cols ≈ √n, the aspect ratio of the grid stays close to 1:1. The trellis chapter has the full layout recipe.

Ternary plot

Plot 3-component compositional data on an equilateral triangle. Each point's position uniquely encodes all three component ratios; constructors normalise so the components sum to 1. The classic use case is soil composition (sand / silt / clay) but ternary diagrams turn up everywhere portfolios sum to a constant.

The demo plots eight reference points from the USDA soil-texture triangle — sand at the bottom-left vertex, silt at bottom-right, clay at the top. The points span the canonical texture classes (sand, sandy loam, loam at the visual centre, silty clay loam, clay…) — the same classification agronomists have used since the Soil Survey Manual published the diagram in 1951.

Source: Soil texture — Wikipedia

Public surface

use wisp_chart::ternary::{TernaryPlot, TernaryPoint};

let points = vec![
    TernaryPoint::new(0.5, 0.3, 0.2, color),
    TernaryPoint::new(0.2, 0.3, 0.5, color),
    // ...
];
let plot = TernaryPlot::new("Sand", "Silt", "Clay", points);
let g = plot.emit_graphics(&theme, Vec2::new(360.0, 360.0));

Barycentric → cartesian

For triangle vertices A (bottom-left), B (bottom-right), C (top), a point with normalised components (a, b, c) lands at a·A + b·B + c·C. v1 draws the triangle outline, internal grid lines at 25 / 50 / 75 %, and one ellipse per point.

Use it when…

  • Three categories must sum to a fixed total (percentages, composition, portfolio weights).
  • Comparing many compositions at once — the triangle reveals clustering that a stacked-bar over time would hide.
  • Sediment / petrology / metallurgy — the textbook home of ternary diagrams.

Contour plot

Draw iso-level contours over a 2D scalar field using marching squares. Useful for topology, terrain, response surfaces, and 2D density visualisations where the lines themselves are the feature.

The demo draws the bivariate normal density Sir Francis Galton popularised in his 1885 quincunx + regression-board demonstration — a single radial Gaussian peak with five nested iso-density contours. The same shape underlies modern density estimation and 2D KDEs.

Source: Galton board — Wikipedia

Public surface

use wisp_chart::contour::ContourPlot;

let field: Vec<f32> = sample_function_on_grid();  // rows × cols
let plot = ContourPlot::new(
    field,
    /* cols */ 48,
    /* rows */ 48,
    vec![0.15, 0.35, 0.55, 0.75, 0.9],   // iso-levels
);
let g = plot.emit_graphics(&theme, viewport);

Marching squares

Each grid cell's four corners are compared to the iso-level threshold; the 16 possible above/below sign-bit combinations map to 16 line-segment cases. The implementation is a flat match — no degenerate ambiguities are smoothed (saddle points pick a fixed orientation), which keeps the output deterministic across runs and platforms.

Contour vs. filled heatmap

  • A 2D histogram or table heatmap shows the bulk of the field via colour — easy to read overall shape.
  • A contour plot shows specific levels — easy to read "everything above 0.75 is here". Compose both: filled heatmap underneath, contour lines on top.

Trellis / small multiples

Tile a grid of mini sub-plots — one chart per category in a row / column / grid layout. Tufte's small multiples.

Public surface

use wisp_chart::multi::{Trellis, TrellisCell};

// 1. Decide grid dimensions.
let trellis = Trellis::new(2, 3, Vec::new());
let cell_viewport = trellis.cell_viewport_px(Vec2::new(600.0, 360.0));

// 2. Build per-cell Graphics at `cell_viewport` sizing.
let mut cells = Vec::new();
for label in ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6"] {
    let plot = Plot::new(fixture_for(label))
        .axes(false)
        .mark(Mark::Bar { value_labels: false })
        .encode(plot::x("category", ScaleKind::Band))
        .encode(plot::y("value", ScaleKind::Linear));
    let g = plot.render(&theme, cell_viewport);
    cells.push(TrellisCell::new(label, g));
}
let trellis = Trellis::new(2, 3, cells);

// 3. Add positioned cells to the stage:
for g in trellis.positioned_cells(Vec2::new(600.0, 360.0)) {
    stage.add_child(root, g);
}
// 4. Add grid borders as a separate Graphics:
let borders = trellis.emit_grid_borders(&theme, Vec2::new(600.0, 360.0));
stage.add_child(root, borders);

Why v1 takes pre-built Graphics

Info

A "true" faceting API would re-build each sub-chart from a filtered slice of the source DataFrame. That requires the chart to expose its render path through an interface — which is specific per chart family. v1 takes the simpler "caller builds the cell, we just tile" approach. The caller can use any chart type (bar / scatter / line / etc.) for each cell.

Cell sizing

Important

The cell's Graphics must be built using trellis.cell_viewport_px(outer) as its viewport so its NDC range maps cleanly onto the cell rectangle. positioned_cells applies translation + scale; if the cell was built against the wrong viewport its content will be off-centre or clipped.

When to use trellis vs other multi-view options

Use casePick
One chart per categoryTrellis
Pairwise scatters of N dimsSPLOM
Many entities × time → colour gridLasagna
Layered axes on one chartMulti-encoding Plot

Scatterplot matrix (SPLOM)

Quickly survey all pairwise relationships in a multi-dimensional dataset — N variables produce an N×N grid where each cell is a scatter of the row's variable vs the column's.

The demo plots Fisher's Iris four flower measurements (sepal length, sepal width, petal length, petal width — all in centimetres) across 12 samples covering the three species. Inspect the bottom-left cell to see petal length × sepal length — the strongest single discriminator between species — and the top-right cell for the weakest (sepal width × any). This is the view Fisher's 1936 paper uses implicitly when arguing for linear discriminant analysis.

Source: Iris flower data set — Wikipedia

Public surface

use wisp_chart::multi::{Splom, SplomDimension};

let s = Splom::new(vec![
    SplomDimension::new("mpg", vec![32.0, 28.0, 22.0, 18.0, 14.0, 12.0]),
    SplomDimension::new("cyl", vec![ 4.0,  4.0,  6.0,  6.0,  8.0,  8.0]),
    SplomDimension::new("hp",  vec![95.0,110.0,150.0,200.0,280.0,300.0]),
    SplomDimension::new("wt",  vec![ 2.2,  2.5,  3.0,  3.6,  4.4,  5.0]),
]);
let g = s.emit_graphics(&theme, Vec2::new(400.0, 400.0));

Diagonal

Info

v1 leaves the diagonal cells blank. A follow-on ticket replaces each diagonal with a small histogram (or density / KDE) of that single dimension. The off-diagonal mini-scatters are the primary read until then.

Sizing

Tip

SPLOM viewports want square aspect ratios so each cell is square — easier to compare angle and density across cells. 4- dimension SPLOM at 400×400 px gives 100×100 px cells, which is already a tight read; for 6+ dims aim for 600+ px on the long edge.

Gantt overview

The v1 Gantt is hyper-specific by design: one concrete API, one concrete fixture (a 2026 renderer roadmap), one concrete render target. Surface stays narrow until ergonomics force change.

Pieces

flowchart TD
    Data["Gantt { range, rows, bars, people }"]:::chart
    Theme["Theme::light() + Wong palette"]:::chart
    Layout["date → x · row → y · divider dates"]:::chart
    Render["wisp::Graphics + wisp::Text + wisp::Mask"]:::wisp
    Node["wisp::scene::Node"]:::wisp

    Data --> Layout
    Theme --> Layout
    Layout --> Render
    Render --> Node

    classDef chart fill:#312e81,stroke:#6366f1,color:#e0e7ff
    classDef wisp fill:#7c2d12,stroke:#ea580c,color:#fed7aa

Pixel-spec (v1, from AUT-180)

  • Canvas: 1920 × 800 px.
  • Left gutter (project labels, right-aligned): 180 px.
  • Header band: 60 px (30 px month strip, 30 px week strip below).
  • Row height: 44 px. Bar height: 28 px (vertically centred). 6 px corner radius.
  • Background: white. Alt-row tint: #fafafa.
  • Week grid: #e5e5e5, 1 px. Month grid: #cccccc, 2 px. Month label sits above the week label.
  • Bar fill: owner colour (Wong palette, hash-assigned). Bar text: owner name, white or black auto-chosen for contrast.

Status today

  • ✅ Data structs (Gantt, Row, Bar, DateRange, PersonMap).
  • ✅ Theme + Wong palette + contrast util.
  • ⏳ Layout math — placeholder module today; lands in chunk 2.
  • Gantt::render(&Theme) -> SceneNode — placeholder module today; lands in chunk 3.

Subsequent chapters fill in as the rendering passes ship.

Why hyper-specific first

Per AUT-180:

This is a purely presentational composition: data goes in, a scene-graph subtree comes out. The first chart we ship is hyper-specific by design: one concrete year, one concrete team, one concrete API, so the implementation has zero degrees of freedom before we iterate on flexibility.

Generalisation (bar / line / area) happens AFTER Gantt v1 has a stable internal shape — not before.

Gantt — data-struct API

v1 is data-struct only. No DSL, no parser, no builder. Future M-CHART.1 may add a builder if ergonomics demand it.

Top level

pub struct Gantt {
    pub range: DateRange,
    pub rows: Vec<Row>,
    pub bars: Vec<Bar>,
    pub people: PersonMap,
}

DateRange

Half-open [start, end). Two constructors:

DateRange::year(2026)                    // [Jan 1, Jan 1 of next year)
DateRange::from(date(2026, 2, 1)..date(2026, 3, 15))

The From<Range<Date>> impl makes inline construction ergonomic in fixtures and tests.

Row

Row::new("vec", "M-VEC")

id is the stable identifier; Bar::row_id references it. label is the string drawn in the left gutter.

Bar

Bar::new(
    "vec",
    date(2026, 2, 1)..date(2026, 3, 15),
    "Matt",
)

Bar::label and Bar::group are Option<String> and default to None. v1 stores group but does not render it.

PersonMap

let mut people = PersonMap::default();
people.insert(Person {
    name: "Matt".into(),
    color: Color::from_hex("#0072b2").unwrap(),
});

Explicit entries override the auto-assigned palette colour. Owners without an entry fall back to Theme::palette auto-assignment.

Serde

The data structs are intentionally serde-friendly — adding #[derive(Serialize, Deserialize)] later is a one-line change per struct. The first ingest format will be CSV (M-CHART.2 / follow-on ticket).

API stability

The public surface is intentionally narrow. Additive changes (more fields with Default impls, more constructors) ship at minor versions. Renames / removals trip cargo semver-checks.

Theme + palette

The visual configuration applied at render time. Theme::light() ships v1; dark + custom themes follow.

Defaults

Theme {
    bg: #ffffff,
    row_alt_bg: Some(#fafafa),
    grid_week: { color: #e5e5e5, width: 1.0 },
    grid_month: { color: #cccccc, width: 2.0 },
    header_bg: #f5f5f5,
    text_primary: #222222,
    text_muted: #888888,
    bar_corner_radius: 6.0,
    bar_height: 28.0,
    row_height: 44.0,
    gutter_width: 180.0,
    header_height: 60.0,
    palette: OwnerPalette::Wong,
}

Wong palette

The default owner-colour palette is Wong's colourblind-friendly 8-colour set:

#ColourHex
0Blue#0072b2
1Vermillion#d55e00
2Bluish green#009e73
3Reddish purple#cc79a7
4Yellow#f0e442
5Sky blue#56b4e9
6Orange#e69f00
7Black#000000

Auto-assignment hashes the owner's name (FNV-1a 64-bit, then modulo). The hash is stable across native + wasm32, so the same fixture renders the same colours in every build.

Contrast-aware bar text

Color::luminance implements the WCAG 2.x relative-luminance formula (sRGB → linear → weighted). contrast_text_color(bg) picks black if bg.luminance() > 0.179 else white. The bar's owner name uses this against the bar's fill.

OwnerPalette variants

pub enum OwnerPalette {
    Wong,                          // default
    Custom(Vec<Color>),            // hash against your own list
    AutoWithOverrides(Vec<Color>), // explicit PersonMap wins; rest hash
}

Override individual owners, keep the default for the rest

The most common case — most owners get auto-assigned, a few get explicit brand colours — uses AutoWithOverrides(WONG-decoded) for the fallback palette and fills PersonMap with the explicit entries.

Time axis

The Gantt's horizontal axis maps Date to pixel x. v1 uses a uniform-buckets approach; ISO-week-correct alignment is a deliberate deferral.

Mapping (v1)

For range = [range.start, range.end) and a plot area of plot_width pixels:

days_total = (range.end - range.start).num_days() as f32
day_index  = (date - range.start).num_days() as f32
x          = (day_index / days_total) * plot_width

This is uniform per day. A year with 365 days maps each day to plot_width / 365 px; a 366-day leap year maps to plot_width / 366 px. Bars across leap-year boundaries are slightly stretched.

Row mapping

For row index i (top to bottom):

y = header_height + i * theme.row_height + (theme.row_height - theme.bar_height) / 2

Bars are centred vertically within their row.

Grid line detection

The renderer walks range.start..range.end once per chart:

  • Emit a week divider at every Monday (ISO weekday 1).
  • Emit a month divider at every day 1.

Month dividers paint AFTER week dividers (heavier, on top).

Why not ISO-week-correct alignment in v1

Per the M-CHART.0 ticket:

v1 uses uniform 52-bucket division. Documented in time-axis.md.

Reasons:

  1. Year-boundary handling is the hard part. ISO-week 53 doesn't always exist; ISO-week 1 can start in the previous calendar year. Getting it right adds branches without product value at this scope.
  2. The 2026 fixture doesn't trip any ISO edge cases. v1's demo year is well-behaved; uniform buckets render visually-correct dividers.
  3. The data API is unchanged. When a follow-on chunk ships ISO-correct alignment, no Gantt / Bar field changes — only the layout module's divider_dates function.

Future: zoom + scroll

A pannable / zoomable axis is M-CHART.5 (parking lot). The v1 chart is a single fixed-extent composition; interactivity ships later.

Gantt interaction — pan + frozen panes + click-to-select

Pan the timeline body in both axes while the date header stays glued to the top and the project gutter stays glued to the left. Click a bar and get its index back via ChartElementId::GanttBar.

What this chapter covers

Three pieces ship in wisp_chart::gantt:

PieceModuleRole
Gantt::emit_with_interactiongantt::renderReturns Graphics + reverse-lookup vector mapping each rendered bar primitive to ChartElementId::GanttBar(idx).
GanttPanControllergantt::panPan state machine. Accumulates pointer drag into a single body_offset, clamps to content bounds.
GanttViewportgantt::panHost-owned pan offset. Four pane transforms derive from it.

The freeze-panes model

Spreadsheets and Gantt-style tools split the viewport into four panes that share a common pan state but apply different masks:

flowchart LR
    subgraph "Viewport (your <canvas>)"
        C[Corner pane — fully frozen]
        H[Header pane — pans X only]
        G[Gutter pane — pans Y only]
        B[Body pane — pans X + Y]
    end
    C -->|same row as| H
    C -->|same column as| G
    H -->|same column as| B
    G -->|same row as| B

The user's pointer drag mutates a single body_offset: Vec2. The four *_offset accessors return a masked copy:

PaneTransformWhy
body_offset(x, y)Timeline content — pan both axes
header_offset(x, 0)Date band stays glued to the top while scrolling down
gutter_offset(0, y)Project labels stay glued to the left while scrolling right
corner_offset(0, 0)The top-left intersection never moves

One offset, four masks

The library does NOT maintain four separate offsets. There is one body_offset; the accessors are pure functions of it. This avoids the classic spreadsheet bug where the header gets out of sync with the body during a fast scroll.

Diagonal pan support

A naive controller written as body_offset.x += delta.x silently drops vertical motion. GanttPanController accumulates the full pointer - anchor delta on both axes, then clamps each against its own content extent:

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp_chart::gantt::{GanttPanController, GanttViewport};
let mut ctrl = GanttPanController::new(
    /* header_height */ 60.0,
    /* gutter_width  */ 180.0,
    /* content_size  */ Vec2::new(2400.0, 800.0),
    /* viewport_size */ Vec2::new(1280.0, 600.0),
);
let mut viewport = GanttViewport::new();

// User presses, drags diagonally, releases.
ctrl.pan_begin(Vec2::new(500.0, 200.0));
ctrl.pan_drag(Vec2::new(440.0, 160.0), &mut viewport);
// body_offset now negative on BOTH axes — content scrolled both
// directions to follow the cursor.
ctrl.pan_end();
}

Clamping

The clamp range collapses to [0, 0] on any axis where the content fits inside its pane. Otherwise:

  • body_offset.x ∈ [body_width - content.x, 0] where body_width = viewport.x - gutter_width.
  • body_offset.y ∈ [body_height - content.y, 0] where body_height = viewport.y - header_height.

0 shows the topmost / leftmost content; the negative bound shows the rightmost / bottommost.

Programmatic scroll

Hosts that want to scroll programmatically (keyboard nav, jump-to- today, click a scrollbar) can mutate viewport.body_offset directly and then call controller.clamp(&mut viewport) to enforce bounds.

Wiring Gantt::emit_with_interaction to clicks

emit_with_interaction returns one element per RENDERED bar mapping its primitive index to ChartElementId::GanttBar(bar_idx). The cosmetic background primitive (always emitted as the first primitive) is NOT in the elements vector — clicks on empty canvas resolve to no gantt bar.

#![allow(unused)]
fn main() {
use wisp_chart::{
    interaction::{ChartElementId, EmittedChart},
    theme::Theme,
    Gantt,
};
use glam::Vec2;
fn demo(gantt: Gantt, theme: &Theme, vp: Vec2) {
let emitted: EmittedChart = gantt.emit_with_interaction(theme, vp);

// On click: caller knows which primitive index was hit; look it up.
let hit_primitive = 1_usize;
if let Some(ChartElementId::GanttBar(bar_idx)) =
    emitted.element_for_primitive(hit_primitive)
{
    println!("user clicked bar at index {bar_idx}");
}
}
}

Bar indices survive skipped rows

Bars whose row_id doesn't match any Row are silently skipped at render time. The ChartElementId::GanttBar(idx) payload preserves the bar's ORIGINAL index in Gantt::bars, so the lookup matches your source data even when elements.len() < self.bars.len().

Why a Gantt-specific controller (not just PanZoomController)

wisp_interaction::PanZoomController applies a single uniform transform to the entire scene — fine for infinite canvases. The spreadsheet-style freeze-panes shape needs FOUR transforms derived from the same offset, which is the whole point of GanttPanController.

If your Gantt is hosted inside a larger PanZoom canvas (e.g. a multi-chart dashboard with a global zoom), nest the controllers: the outer PanZoomController mutates a Viewport2D; the inner GanttPanController lives inside a single chart's pane and pans within that pane.

Sample end-to-end host wiring

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::rc::Rc;
use glam::Vec2;
use wisp_chart::{
    gantt::{GanttPanController, GanttViewport},
    theme::Theme,
    Gantt,
};
use wisp_interaction::{CallbackRegistry, PointerDispatcher, MouseButton, PointerLocation, PointerId, ModifierState};
fn wire(gantt: Gantt, theme: &Theme, viewport_px: Vec2) {
let emitted = gantt.emit_with_interaction(theme, viewport_px);

// Pan controller, owned by the host.
let content_size = Vec2::new(2400.0, 800.0); // host computes from row count + time range
let mut ctrl = GanttPanController::new(
    theme.gantt.header_height,
    theme.gantt.gutter_width,
    content_size,
    viewport_px,
);
let viewport = Rc::new(RefCell::new(GanttViewport::new()));

// Adapter wires pointer events to the controller.
// (sketch — your adapter will fill in the actual canvas listeners.)
}
}

For a complete native adapter pattern, see the wisp-interaction adapters chapter. For the browser-side adapter pattern, the wisp-3d-web rAF loop is the reference (see the wisp-3d-web demo).

2026 renderer roadmap — the hyper-specific demo

The first concrete Gantt: nine rows for the M-* milestones, one bar per milestone, three owners. Used as the shared fixture by:

  1. The snapshot test tests/gantt_snapshot.rs.
  2. The storybook story s_chart_gantt_2026_roadmap.
  3. This chapter (hero PNG).

When the fixture changes, all three regenerate together.

Target visual

gantt
    title 2026 Renderer Roadmap — target visual
    dateFormat YYYY-MM-DD
    axisFormat %b

    section Renderer
    M-VEC     :2026-02-01, 6w
    M-MASK    :2026-03-01, 7w
    M-BLEND   :2026-04-10, 7w
    M-FILTER  :2026-04-25, 7w
    M-TEXT    :2026-05-15, 7w
    M-BOOL    :2026-07-01, 11w
    M-CHART   :2026-09-15, 9w

    section App
    M-INT     :2026-08-01, 11w
    M-EXPORT  :2026-10-01, 11w

Mermaid here, wgpu in production

The diagram above is rendered by mdBook's mermaid plugin so the book always shows the intent. The hero PNG (lands when the render pass ships) is the actual wgpu output. They're separate artefacts on purpose — the mermaid version is for the book, the wgpu PNG is for product fidelity verification.

Hero asset (regenerated by just snapshots)

When chunk 3 (render pass) lands, this section embeds:

![2026 roadmap rendered by wisp-chart](../../../assets/wisp-chart/gantt-2026.png)

Try it live

Run wisp-chart in Chrome via WebGPU — the same fixture rendered in a browser tab.

Run wisp-chart in Chrome via WebGPU

wisp-chart compiles for wasm32-unknown-unknown, so the same chart code that runs natively in the recorder also renders into a <canvas> in a browser tab.

The demo crate

crates/wisp-chart-web/ is a sibling crate of wisp-chart. It exists only on wasm32-unknown-unknown — Trunk builds it into a self-contained index.html + *.wasm + glue JS bundle.

sequenceDiagram
    participant HTML as index.html
    participant WASM as wisp_chart_web.wasm
    participant CHART as wisp-chart
    participant WGPU as wgpu (BROWSER_WEBGPU)
    participant CANVAS as <canvas>

    HTML->>WASM: load + start()
    WASM->>CANVAS: get_element_by_id
    WASM->>WGPU: Instance::new(BROWSER_WEBGPU)
    WGPU->>CANVAS: Surface::from(HtmlCanvasElement)
    WASM->>CHART: Gantt fixture
    CHART-->>WASM: SceneNode
    WASM->>WGPU: render(scene, surface)
    WGPU-->>CANVAS: pixels

Local dev

just dev-wisp-chart-demo

Opens http://127.0.0.1:8080. Hot-rebuilds on file change.

Deployed

The CI deploy at /Screen/wisp-chart/demo/ hosts the latest main build of the same crate. Open it in any WebGPU-capable browser:

  • Chrome / Edge 113+ (WebGPU on by default).
  • Firefox 121+ on Linux / macOS / Windows.
  • Safari Technology Preview (WebGPU shipping pending).

Browser support reality check

WebGPU is not WebGL

WebGPU is the modern standard but availability lags WebGL. On Linux specifically, Chromium needs Vulkan; some headless CI configurations require flags like --enable-unsafe-webgpu --use-vulkan=swiftshader. The CI gate's optional Tier-C job exercises this configuration; the deployed demo assumes a WebGPU-capable user agent.

What this demo is and is not

  • The same wisp-chart crate. No demo-only fork; the WebGPU path is identical.
  • The same wisp::Graphics + wisp::Text + masks. wisp itself is wasm32-clean (winit is a dev-only dep there).
  • Not the same surface-creation code. Native uses winit::Windowwgpu::Surface; web uses HtmlCanvasElementwgpu::Surface. wisp-chart's output doesn't care which.