diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d31f05..fa9a844 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **`heuropt-plot` companion crate (v0.1.0)** at `heuropt-plot/`,
+ published independently. Lightweight SVG-only plotter for Pareto
+ fronts (`pareto_front_svg`) and convergence traces
+ (`convergence_svg`) — hand-rolled SVG output, no `plotters` /
+ `tiny-skia` dep so the crate stays a tiny optional addition.
+- `examples/visualize.rs` — runs NSGA-II on Schaffer N.1 with a
+ closure observer that records hypervolume per generation, then
+ emits `pareto_front.svg` + `convergence.svg` via `heuropt-plot`.
+
## [0.7.0] — 2026-05-05
Theme: async evaluation. heuropt now supports problems where each
diff --git a/Cargo.toml b/Cargo.toml
index 70a2b1e..68d9e17 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,3 +1,6 @@
+[workspace]
+members = [".", "heuropt-plot"]
+
[package]
name = "heuropt"
version = "0.7.0"
@@ -30,6 +33,7 @@ tracing = { version = "0.1", optional = true, default-features = false, features
[dev-dependencies]
gungraun = "0.18"
+heuropt-plot = { path = "heuropt-plot" }
proptest = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
diff --git a/docs/book/convergence.svg b/docs/book/convergence.svg
new file mode 100644
index 0000000..1578011
--- /dev/null
+++ b/docs/book/convergence.svg
@@ -0,0 +1,26 @@
+
\ No newline at end of file
diff --git a/docs/book/pareto_front.svg b/docs/book/pareto_front.svg
new file mode 100644
index 0000000..431f20f
--- /dev/null
+++ b/docs/book/pareto_front.svg
@@ -0,0 +1,73 @@
+
\ No newline at end of file
diff --git a/examples/visualize.rs b/examples/visualize.rs
new file mode 100644
index 0000000..4335849
--- /dev/null
+++ b/examples/visualize.rs
@@ -0,0 +1,89 @@
+//! Visualize an NSGA-II run on Schaffer N.1 — produces two SVGs:
+//! `pareto_front.svg` (scatter plot of the final front) and
+//! `convergence.svg` (best-so-far hypervolume per generation).
+//!
+//! Uses the `heuropt-plot` companion crate plus the v0.6 observer
+//! API (`Periodic`) to record per-generation hypervolume into a Vec
+//! during the run.
+//!
+//! Run with: `cargo run --release --example visualize`
+
+use std::cell::RefCell;
+use std::ops::ControlFlow;
+
+use heuropt::metrics::hypervolume_2d;
+use heuropt::prelude::*;
+use heuropt_plot::{convergence_svg, pareto_front_svg};
+
+struct Schaffer;
+
+impl Problem for Schaffer {
+ type Decision = Vec;
+ fn objectives(&self) -> ObjectiveSpace {
+ ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
+ }
+ fn evaluate(&self, x: &Vec) -> Evaluation {
+ Evaluation::new(vec![x[0] * x[0], (x[0] - 2.0).powi(2)])
+ }
+}
+
+fn main() {
+ let problem = Schaffer;
+ let bounds = vec![(-5.0_f64, 5.0_f64)];
+ let space = problem.objectives();
+ let ref_point = [10.0, 10.0];
+
+ // Per-generation hypervolume trace, recorded by the observer.
+ let history: RefCell> = RefCell::new(Vec::new());
+
+ let mut recorder = |snap: &Snapshot<'_, Vec>| -> ControlFlow<()> {
+ let hv = match snap.pareto_front {
+ Some(front) => hypervolume_2d(front, snap.objectives, ref_point),
+ None => 0.0,
+ };
+ history.borrow_mut().push(hv);
+ ControlFlow::Continue(())
+ };
+
+ let mut opt = Nsga2::new(
+ Nsga2Config {
+ population_size: 50,
+ generations: 100,
+ seed: 42,
+ },
+ RealBounds::new(bounds.clone()),
+ CompositeVariation {
+ crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
+ mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
+ },
+ );
+
+ let result = opt.run_with(&problem, &mut recorder);
+
+ let front_svg = pareto_front_svg(
+ &result.pareto_front,
+ &space,
+ 700,
+ 450,
+ "NSGA-II on Schaffer N.1 — final Pareto front",
+ );
+ std::fs::write("pareto_front.svg", front_svg).expect("write pareto_front.svg");
+
+ let trace = history.borrow();
+ let conv_svg = convergence_svg(
+ &trace,
+ 700,
+ 450,
+ "NSGA-II on Schaffer N.1 — hypervolume per generation",
+ "hypervolume",
+ false, // higher is better
+ );
+ std::fs::write("convergence.svg", conv_svg).expect("write convergence.svg");
+
+ println!("Final front size: {}", result.pareto_front.len());
+ println!(
+ "Final hypervolume: {:.4}",
+ trace.last().copied().unwrap_or(0.0)
+ );
+ println!("Wrote pareto_front.svg and convergence.svg");
+}
diff --git a/heuropt-plot/Cargo.toml b/heuropt-plot/Cargo.toml
new file mode 100644
index 0000000..f3130df
--- /dev/null
+++ b/heuropt-plot/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "heuropt-plot"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.85"
+authors = ["Stephen Waits "]
+description = "Lightweight SVG visualization for heuropt Pareto fronts and convergence traces."
+license = "MIT"
+readme = "README.md"
+repository = "https://github.com/swaits/heuropt"
+homepage = "https://github.com/swaits/heuropt"
+documentation = "https://docs.rs/heuropt-plot"
+keywords = ["optimization", "pareto", "svg", "plotting", "heuropt"]
+categories = ["algorithms", "visualization"]
+
+[dependencies]
+heuropt = { version = "0.7", path = ".." }
diff --git a/heuropt-plot/README.md b/heuropt-plot/README.md
new file mode 100644
index 0000000..eb2516c
--- /dev/null
+++ b/heuropt-plot/README.md
@@ -0,0 +1,46 @@
+# heuropt-plot
+
+[](https://crates.io/crates/heuropt-plot)
+[](https://docs.rs/heuropt-plot)
+[](../LICENSE)
+
+Lightweight SVG plotting helpers for [`heuropt`](https://crates.io/crates/heuropt)
+results.
+
+Hand-rolled SVG output (no `plotters`, no `tiny-skia`, no
+heavyweight dependency) so adding `heuropt-plot` to your project
+costs ~20 KB of compiled code.
+
+## What's in the box
+
+- `pareto_front_svg` — render a 2-objective Pareto front as an SVG
+ scatter plot with axes and labels.
+- `convergence_svg` — render a "best fitness so far" trace as an
+ SVG line plot.
+
+Output is a `String` of valid SVG. Write it to a file, embed it in
+HTML, or pipe it to a browser.
+
+## Example
+
+```rust
+use heuropt::prelude::*;
+use heuropt_plot::pareto_front_svg;
+
+let space = ObjectiveSpace::new(vec![
+ Objective::minimize("f1"),
+ Objective::minimize("f2"),
+]);
+let front = vec![
+ Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
+ Candidate::new((), Evaluation::new(vec![0.5, 0.5])),
+ Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
+];
+
+let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front");
+std::fs::write("front.svg", svg).unwrap();
+```
+
+## License
+
+MIT — see [LICENSE](../LICENSE) at the repo root.
diff --git a/heuropt-plot/src/lib.rs b/heuropt-plot/src/lib.rs
new file mode 100644
index 0000000..bd9f036
--- /dev/null
+++ b/heuropt-plot/src/lib.rs
@@ -0,0 +1,368 @@
+//! Lightweight SVG plotting helpers for `heuropt` results.
+//!
+//! Two core primitives:
+//!
+//! - [`pareto_front_svg`] — render a 2-objective Pareto front as an
+//! SVG scatter plot with axes and labels.
+//! - [`convergence_svg`] — render a per-generation "best-fitness so
+//! far" trace as an SVG line plot.
+//!
+//! Hand-rolled SVG output (no `plotters` / `tiny-skia` dep) so the
+//! crate stays a tiny optional dependency. Output is a `String` of
+//! valid SVG — write it to a file, embed it in HTML, or pipe it to a
+//! browser.
+//!
+//! # Example
+//!
+//! ```
+//! use heuropt::prelude::*;
+//! use heuropt_plot::pareto_front_svg;
+//!
+//! let space = ObjectiveSpace::new(vec![
+//! Objective::minimize("f1"),
+//! Objective::minimize("f2"),
+//! ]);
+//! let front = vec![
+//! Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
+//! Candidate::new((), Evaluation::new(vec![0.5, 0.5])),
+//! Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
+//! ];
+//! let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front");
+//! assert!(svg.starts_with("