use std::fmt::Write;
use crate::figure::{
Annotation, AxesStyle, Clip, ColorbarConfig, ColorbarPosition, Figure, GridStyle,
LegendPosition, Limit, Projection,
};
use crate::hatch::{self, Hatch};
use crate::markers::{self, Marker};
use crate::scale::{auto_domain, Scale, ScaleKind};
use crate::series::{
AreaSeries, BarSeries, BoxPlotSeries, ContourSeries, Err, ErrorBarSeries, HeatmapSeries,
HistogramSeries, HlineSeries, LineSeries, Origin, PolygonSeries, QuiverSeries, ScatterSeries,
Series, StemSeries, VlineSeries,
};
use crate::strokes::Stroke;
use crate::svg;
use crate::theme::{TickDirection, Theme, TitleCase};
use crate::ticks;
use crate::title_block::{TitleBlock, TitleBlockPosition};
pub fn render(fig_in: &Figure) -> String {
let mut fig = fig_in.clone();
auto_adjust_margins(&mut fig);
let (px, py, pw, ph) = fig.plot_rect();
let theme = &fig.theme;
let categorical = fig
.series
.iter()
.any(|s| matches!(s, Series::Bar(_) | Series::BoxPlot(_)));
let (xlim, categories) = resolve_xlim(&fig, categorical);
let ylim = resolve_ylim(&fig);
let xscale = build_scale(fig.xscale, xlim, (px, px + pw));
let yscale = build_scale(fig.yscale, ylim, (py + ph, py));
let mut buf = String::with_capacity(8 * 1024);
svg::document_open(&mut buf, fig.width, fig.height);
let bg_attrs = format!(" fill=\"{}\"", theme.background);
svg::rect(&mut buf, 0.0, 0.0, fig.width, fig.height, &bg_attrs);
if theme.border {
let inset = theme.border_inset;
let attrs = svg::Attrs::new()
.str("fill", "none")
.str("stroke", theme.foreground)
.num("stroke-width", theme.border_stroke_width)
.into_string();
svg::rect(
&mut buf,
inset,
inset,
fig.width - 2.0 * inset,
fig.height - 2.0 * inset,
&attrs,
);
}
let used_hatches = collect_patterns(&fig);
if !used_hatches.is_empty() {
buf.push_str("<defs>");
hatch::write_defs(&mut buf, &used_hatches);
buf.push_str("</defs>");
}
write_title(&mut buf, &fig, theme, px, pw);
if !matches!(fig.axes_style, AxesStyle::None) {
write_grid(&mut buf, &fig, theme, px, py, pw, ph, &xscale, &yscale);
}
write_series(
&mut buf,
&fig,
theme,
&xscale,
&yscale,
&categories,
(px, py, pw, ph),
);
if !matches!(fig.axes_style, AxesStyle::None) {
write_axes(
&mut buf,
&fig,
theme,
px,
py,
pw,
ph,
&xscale,
&yscale,
categorical,
&categories,
);
write_axis_labels(&mut buf, &fig, theme, px, py, pw, ph);
}
write_legend(&mut buf, &fig, theme, px, py, pw, ph);
if let Some(cb) = &fig.colorbar {
write_colorbar(&mut buf, &fig, theme, cb, px, py, pw, ph);
}
if !fig.annotations.is_empty() {
write_annotations(&mut buf, &fig, theme, &xscale, &yscale);
}
if let Some(tb) = &fig.title_block {
write_title_block(&mut buf, tb, theme, fig.width, fig.height);
}
svg::document_close(&mut buf);
buf
}
fn project(p: Projection, x: f64, y: f64) -> (f64, f64) {
match p {
Projection::None => (x, y),
Projection::Polar => crate::polar::project(x, y),
Projection::Mercator => crate::geo::mercator(x, y),
Projection::Equirect => crate::geo::equirect(x, y),
}
}
fn project_pair(fig: &Figure, x: f64, y: f64) -> (f64, f64) {
project(fig.projection, x, y)
}
fn project_to_pixel(fig: &Figure, xs: &Scale, ys: &Scale, x: f64, y: f64) -> (f64, f64) {
let (px_d, py_d) = project_pair(fig, x, y);
(xs.project(px_d), ys.project(py_d))
}
fn build_scale(kind: ScaleKind, domain: (f64, f64), range: (f64, f64)) -> Scale {
match kind {
ScaleKind::Linear => Scale::linear(domain, range),
ScaleKind::Log => Scale::log(domain, range, 10.0),
}
}
fn auto_adjust_margins(fig: &mut Figure) {
if let Some(tb) = &fig.title_block {
let needed = tb.height + fig.theme.border_inset + 56.0;
let (t, r, b, l) = fig.margins;
if b < needed {
fig.margins = (t, r, needed, l);
}
}
if let Some(cb) = &fig.colorbar {
let (t, r, b, l) = fig.margins;
match cb.position {
ColorbarPosition::Right => {
if r < 110.0 {
fig.margins = (t, 110.0, b, l);
}
}
ColorbarPosition::Left => {
if l < 130.0 {
fig.margins = (t, r, b, 130.0);
}
}
ColorbarPosition::Bottom => {
if b < 80.0 {
fig.margins = (t, r, 80.0, l);
}
}
ColorbarPosition::Manual(_, _) => {}
}
}
}
fn resolve_xlim(fig: &Figure, categorical: bool) -> ((f64, f64), Vec<String>) {
if let Limit::Manual(a, b) = fig.xlim {
let (a, b) = match fig.projection {
Projection::None | Projection::Polar => (a, b),
Projection::Mercator | Projection::Equirect => {
let (xa, _) = project_pair(fig, a, 0.0);
let (xb, _) = project_pair(fig, b, 0.0);
(xa, xb)
}
};
return ((a, b), Vec::new());
}
if categorical {
let mut cats: Vec<String> = Vec::new();
for s in &fig.series {
match s {
Series::Bar(b) => {
for c in &b.categories {
if !cats.iter().any(|x| x == c) {
cats.push(c.clone());
}
}
}
Series::BoxPlot(b) => {
for c in &b.categories {
if !cats.iter().any(|x| x == c) {
cats.push(c.clone());
}
}
}
_ => {}
}
}
let n = cats.len() as f64;
return ((-0.5, n - 0.5), cats);
}
let xs = collect_x_values(fig);
if xs.is_empty() {
return ((0.0, 1.0), Vec::new());
}
let pad = if matches!(fig.xscale, ScaleKind::Log) {
0.0
} else {
0.02
};
(auto_domain(&xs, pad), Vec::new())
}
fn resolve_ylim(fig: &Figure) -> (f64, f64) {
if let Limit::Manual(a, b) = fig.ylim {
let (a, b) = match fig.projection {
Projection::None | Projection::Polar => (a, b),
Projection::Mercator | Projection::Equirect => {
let (_, ya) = project_pair(fig, 0.0, a);
let (_, yb) = project_pair(fig, 0.0, b);
(ya, yb)
}
};
return (a, b);
}
let ys = collect_y_values(fig);
if ys.is_empty() {
return (0.0, 1.0);
}
let pad = if matches!(fig.yscale, ScaleKind::Log) {
0.0
} else {
0.08
};
auto_domain(&ys, pad)
}
fn collect_x_values(fig: &Figure) -> Vec<f64> {
let mut out = Vec::new();
let proj = fig.projection;
let push_xy = |x: f64, y: f64, out: &mut Vec<f64>| {
let (xp, _) = project(proj, x, y);
out.push(xp);
};
for s in &fig.series {
match s {
Series::Line(l) => {
for (x, y) in l.xs.iter().zip(l.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::Scatter(s) => {
for (x, y) in s.xs.iter().zip(s.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::Area(a) => {
for (x, y) in a.xs.iter().zip(a.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::Polygon(p) => {
for (x, y) in p.xs.iter().zip(p.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::ErrorBar(e) => {
for (x, y) in e.xs.iter().zip(e.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::Stem(s) => {
for (x, y) in s.xs.iter().zip(s.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::Quiver(q) => {
for (((x, y), u), v) in q
.xs
.iter()
.zip(q.ys.iter())
.zip(q.us.iter())
.zip(q.vs.iter())
{
push_xy(*x, *y, &mut out);
push_xy(*x + *u * q.scale, *y + *v * q.scale, &mut out);
}
}
Series::Contour(c) => {
if let (Some(&lo), Some(&hi)) = (c.x_edges.first(), c.x_edges.last()) {
out.push(lo);
out.push(hi);
}
}
Series::Histogram(h) => out.extend_from_slice(&h.bin_edges),
Series::Heatmap(h) => {
if let (Some(&lo), Some(&hi)) = (h.x_edges.first(), h.x_edges.last()) {
out.push(lo);
out.push(hi);
}
}
Series::Vline(v) => out.push(v.x),
Series::Bar(_) | Series::BoxPlot(_) | Series::Hline(_) => {}
}
}
out
}
fn collect_y_values(fig: &Figure) -> Vec<f64> {
let mut out = Vec::new();
let proj = fig.projection;
let push_xy = |x: f64, y: f64, out: &mut Vec<f64>| {
let (_, yp) = project(proj, x, y);
out.push(yp);
};
for s in &fig.series {
match s {
Series::Line(l) => {
for (x, y) in l.xs.iter().zip(l.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::Scatter(s) => {
for (x, y) in s.xs.iter().zip(s.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::Area(a) => {
for (x, y) in a.xs.iter().zip(a.ys.iter()) {
push_xy(*x, *y, &mut out);
}
out.push(a.baseline);
}
Series::Polygon(p) => {
for (x, y) in p.xs.iter().zip(p.ys.iter()) {
push_xy(*x, *y, &mut out);
}
}
Series::ErrorBar(e) => {
for (i, (x, y)) in e.xs.iter().zip(e.ys.iter()).enumerate() {
push_xy(*x, *y, &mut out);
if let Some(yerr) = &e.yerr {
if let Some(err) = yerr.get(i) {
let (lo, hi) = err_bounds(*err);
push_xy(*x, *y - lo, &mut out);
push_xy(*x, *y + hi, &mut out);
}
}
}
}
Series::BoxPlot(b) => {
for s in &b.stats {
out.push(s.min);
out.push(s.max);
out.push(s.q1);
out.push(s.q3);
for o in &s.outliers {
out.push(*o);
}
}
}
Series::Stem(s) => {
for (x, y) in s.xs.iter().zip(s.ys.iter()) {
push_xy(*x, *y, &mut out);
}
out.push(s.baseline);
}
Series::Quiver(q) => {
for (((x, y), u), v) in q
.xs
.iter()
.zip(q.ys.iter())
.zip(q.us.iter())
.zip(q.vs.iter())
{
push_xy(*x, *y, &mut out);
push_xy(*x + *u * q.scale, *y + *v * q.scale, &mut out);
}
}
Series::Contour(c) => {
if let (Some(&lo), Some(&hi)) = (c.y_edges.first(), c.y_edges.last()) {
out.push(lo);
out.push(hi);
}
}
Series::Histogram(h) => {
out.push(0.0);
out.extend_from_slice(&h.values);
}
Series::Bar(b) => {
out.push(0.0);
out.extend_from_slice(&b.values);
}
Series::Heatmap(h) => {
if let (Some(&lo), Some(&hi)) = (h.y_edges.first(), h.y_edges.last()) {
out.push(lo);
out.push(hi);
}
}
Series::Hline(h) => out.push(h.y),
Series::Vline(_) => {}
}
}
out
}
fn err_bounds(e: Err) -> (f64, f64) {
match e {
Err::Symmetric(v) => (v, v),
Err::Asymmetric(lo, hi) => (lo, hi),
}
}
fn collect_patterns(fig: &Figure) -> Vec<Hatch> {
let mut out = Vec::new();
let mut idx = 0;
for s in &fig.series {
match s {
Series::Bar(b) => {
let h = b.hatch.unwrap_or_else(|| Hatch::cycle(idx));
push_unique(&mut out, h);
idx += 1;
}
Series::Area(a) => {
let h = a.hatch.unwrap_or_else(|| Hatch::cycle(idx));
push_unique(&mut out, h);
idx += 1;
}
Series::Polygon(p) => {
if let Some(h) = p.hatch {
push_unique(&mut out, h);
}
idx += 1;
}
Series::BoxPlot(b) => {
let h = b.hatch.unwrap_or_else(|| Hatch::cycle(idx));
push_unique(&mut out, h);
idx += 1;
}
Series::Histogram(h) => {
let hh = h.hatch.unwrap_or_else(|| Hatch::cycle(idx));
push_unique(&mut out, hh);
idx += 1;
}
Series::Heatmap(h) => {
for hh in &h.ramp {
push_unique(&mut out, *hh);
}
if let Some(cb) = &fig.colorbar {
if let Some(ramp) = &cb.ramp {
for hh in ramp {
push_unique(&mut out, *hh);
}
}
}
}
_ => {}
}
}
out
}
fn push_unique(out: &mut Vec<Hatch>, h: Hatch) {
if !out.contains(&h) {
out.push(h);
}
}
fn write_title(buf: &mut String, fig: &Figure, theme: &Theme, px: f64, pw: f64) {
let (top_margin, _, _, _) = fig.margins;
let base_y = (top_margin / 2.0) + 2.0;
let center_x = px + pw / 2.0;
if let Some(title) = &fig.title {
let display = theme.transform_title(title);
let attrs = svg::Attrs::new()
.num("font-size", theme.title_font_size)
.str("font-family", theme.title_font_family)
.str("text-anchor", "middle")
.str("letter-spacing", theme.title_letter_spacing)
.str("fill", theme.foreground)
.into_string();
svg::text(buf, center_x, base_y, &svg::escape(&display), &attrs);
}
if let Some(subtitle) = &fig.subtitle {
let attrs = svg::Attrs::new()
.num("font-size", theme.subtitle_font_size)
.str("font-family", theme.title_font_family)
.str("text-anchor", "middle")
.str("fill", theme.foreground)
.str("font-style", "italic")
.into_string();
svg::text(
buf,
center_x,
base_y + theme.title_font_size + 4.0,
&svg::escape(subtitle),
&attrs,
);
}
}
fn write_grid(
buf: &mut String,
fig: &Figure,
theme: &Theme,
px: f64,
py: f64,
pw: f64,
ph: f64,
xscale: &Scale,
yscale: &Scale,
) {
if matches!(fig.grid, GridStyle::None) {
return;
}
let xticks = tick_values(fig.xscale, xscale.domain());
let yticks = tick_values(fig.yscale, yscale.domain());
svg::group_open(buf, " opacity=\"0.7\"");
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", theme.grid_stroke_width)
.str("stroke-dasharray", theme.grid_dasharray)
.into_string();
for v in xticks {
let x = xscale.project(v);
svg::line(buf, x, py, x, py + ph, &attrs);
}
for v in yticks {
let y = yscale.project(v);
svg::line(buf, px, y, px + pw, y, &attrs);
}
svg::group_close(buf);
}
fn tick_values(kind: ScaleKind, domain: (f64, f64)) -> Vec<f64> {
match kind {
ScaleKind::Linear => ticks::nice(domain, 6),
ScaleKind::Log => ticks::log_nice(domain, 10.0),
}
}
fn write_series(
buf: &mut String,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
categories: &[String],
plot_rect: (f64, f64, f64, f64),
) {
let clip_id = "bland-clip-plot";
let (px, py, pw, ph) = plot_rect;
let _ = write!(buf, "<defs><clipPath id=\"{}\">", clip_id);
match fig.clip {
Clip::Circle => {
let cx = px + pw / 2.0;
let cy = py + ph / 2.0;
let r = pw.min(ph) / 2.0;
let _ = write!(
buf,
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\"/>",
svg::num(cx),
svg::num(cy),
svg::num(r),
);
}
Clip::Rect => {
let _ = write!(
buf,
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/>",
svg::num(px),
svg::num(py),
svg::num(pw),
svg::num(ph),
);
}
}
buf.push_str("</clipPath></defs>");
let group_attrs = format!(" clip-path=\"url(#{})\"", clip_id);
svg::group_open(buf, &group_attrs);
for (i, s) in fig.series.iter().enumerate() {
match s {
Series::Line(l) => draw_line(buf, l, i, fig, theme, xscale, yscale),
Series::Scatter(s) => draw_scatter(buf, s, i, fig, theme, xscale, yscale),
Series::Bar(b) => draw_bar(buf, b, i, theme, xscale, yscale, &fig.series, categories),
Series::Area(a) => draw_area(buf, a, i, fig, theme, xscale, yscale),
Series::Polygon(p) => draw_polygon(buf, p, i, fig, theme, xscale, yscale),
Series::ErrorBar(e) => draw_errorbar(buf, e, fig, theme, xscale, yscale),
Series::BoxPlot(b) => {
draw_boxplot(buf, b, i, theme, xscale, yscale, &fig.series, categories)
}
Series::Stem(s) => draw_stem(buf, s, i, fig, theme, xscale, yscale),
Series::Quiver(q) => draw_quiver(buf, q, fig, theme, xscale, yscale),
Series::Contour(c) => draw_contour(buf, c, fig, theme, xscale, yscale),
Series::Histogram(h) => draw_histogram(buf, h, i, theme, xscale, yscale),
Series::Heatmap(h) => draw_heatmap(buf, h, xscale, yscale),
Series::Hline(h) => draw_hline(buf, h, theme, px, pw, yscale),
Series::Vline(v) => draw_vline(buf, v, theme, py, ph, xscale),
}
}
svg::group_close(buf);
}
fn draw_line(
buf: &mut String,
l: &LineSeries,
index: usize,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let stroke = l.stroke.unwrap_or_else(|| Stroke::cycle(index));
let sw = l.stroke_width.unwrap_or(theme.series_stroke_width);
let points: Vec<(f64, f64)> = l
.xs
.iter()
.zip(l.ys.iter())
.map(|(x, y)| project_to_pixel(fig, xscale, yscale, *x, *y))
.collect();
if points.is_empty() {
return;
}
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.str("stroke-linecap", "round")
.str("stroke-linejoin", "round")
.opt_str("stroke-dasharray", stroke.dasharray())
.into_string();
svg::polyline(buf, &points, &attrs);
if l.markers {
let marker = l.marker.unwrap_or_else(|| Marker::cycle(index));
let size = l.marker_size.unwrap_or(theme.marker_size);
for (x, y) in &points {
markers::draw(buf, marker, *x, *y, size, theme.marker_stroke_width);
}
}
}
fn draw_scatter(
buf: &mut String,
s: &ScatterSeries,
index: usize,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let marker = s.marker.unwrap_or_else(|| Marker::cycle(index));
let size = s.marker_size.unwrap_or(theme.marker_size);
let sw = s.stroke_width.unwrap_or(theme.marker_stroke_width);
for (x, y) in s.xs.iter().zip(s.ys.iter()) {
let (cx, cy) = project_to_pixel(fig, xscale, yscale, *x, *y);
markers::draw(buf, marker, cx, cy, size, sw);
}
}
fn draw_bar(
buf: &mut String,
b: &BarSeries,
index: usize,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
all_series: &[Series],
categories: &[String],
) {
let hatch = b.hatch.unwrap_or_else(|| Hatch::cycle(index));
let sw = b.stroke_width.unwrap_or(theme.series_stroke_width);
let mut groups: Vec<u32> = Vec::new();
for s in all_series {
if let Series::Bar(bs) = s {
if !groups.contains(&bs.group) {
groups.push(bs.group);
}
}
}
let group_count = groups.len().max(1);
let group_index = groups.iter().position(|g| *g == b.group).unwrap_or(0);
let slot_width = (xscale.project(1.0) - xscale.project(0.0)).abs();
let bar_width = slot_width / group_count as f64 * 0.8;
let offset =
(group_index as f64 - (group_count as f64 - 1.0) / 2.0) * (bar_width * 1.05);
let baseline_y = yscale.project(0.0);
let attrs = format!(
" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\"",
bar_fill(hatch),
theme.foreground,
svg::num(sw)
);
for (cat, &v) in b.categories.iter().zip(b.values.iter()) {
let Some(idx) = categories.iter().position(|c| c == cat) else {
continue;
};
let cx = xscale.project(idx as f64) + offset;
let y = yscale.project(v);
let top = y.min(baseline_y);
let h = (y - baseline_y).abs();
svg::rect(buf, cx - bar_width / 2.0, top, bar_width, h, &attrs);
}
}
fn bar_fill(hatch: Hatch) -> String {
hatch.fill_value()
}
fn draw_area(
buf: &mut String,
a: &AreaSeries,
index: usize,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let hatch = a.hatch.unwrap_or_else(|| Hatch::cycle(index));
let stroke = a.stroke.unwrap_or(Stroke::Solid);
let sw = a.stroke_width.unwrap_or(theme.series_stroke_width);
let base_y = yscale.project(a.baseline);
let points: Vec<(f64, f64)> = a
.xs
.iter()
.zip(a.ys.iter())
.map(|(x, y)| project_to_pixel(fig, xscale, yscale, *x, *y))
.collect();
if points.is_empty() {
return;
}
let first_x = points.first().unwrap().0;
let last_x = points.last().unwrap().0;
let mut closed = Vec::with_capacity(points.len() + 2);
closed.push((first_x, base_y));
closed.extend_from_slice(&points);
closed.push((last_x, base_y));
let fill_attrs = format!(" fill=\"{}\" stroke=\"none\"", hatch.fill_value());
svg::polygon(buf, &closed, &fill_attrs);
let outline_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.opt_str("stroke-dasharray", stroke.dasharray())
.str("stroke-linejoin", "round")
.into_string();
svg::polyline(buf, &points, &outline_attrs);
}
fn draw_histogram(
buf: &mut String,
h: &HistogramSeries,
index: usize,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let hatch = h.hatch.unwrap_or_else(|| Hatch::cycle(index));
let sw = h.stroke_width.unwrap_or(theme.series_stroke_width);
let baseline_y = yscale.project(0.0);
let attrs = format!(
" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" stroke-linejoin=\"miter\"",
hatch.fill_value(),
theme.foreground,
svg::num(sw)
);
for (i, &v) in h.values.iter().enumerate() {
let lo = h.bin_edges[i];
let hi = h.bin_edges[i + 1];
let x_left = xscale.project(lo);
let x_right = xscale.project(hi);
let y_top = yscale.project(v);
let top = y_top.min(baseline_y);
let height = (y_top - baseline_y).abs();
let width = (x_right - x_left).abs();
svg::rect(buf, x_left.min(x_right), top, width, height, &attrs);
}
}
fn draw_heatmap(buf: &mut String, h: &HeatmapSeries, xscale: &Scale, yscale: &Scale) {
if h.data.is_empty() || h.ramp.is_empty() {
return;
}
let n_levels = h.ramp.len();
let (lo, hi) = match h.range {
Some(r) => r,
None => hatch::extent(&h.data),
};
let n_rows = h.data.len();
for (ri, row) in h.data.iter().enumerate() {
let y_index = match h.origin {
Origin::TopLeft => n_rows - 1 - ri,
Origin::BottomLeft => ri,
};
if y_index + 1 >= h.y_edges.len() {
continue;
}
let y_lo = h.y_edges[y_index];
let y_hi = h.y_edges[y_index + 1];
for (ci, &val) in row.iter().enumerate() {
if ci + 1 >= h.x_edges.len() {
continue;
}
let x_lo = h.x_edges[ci];
let x_hi = h.x_edges[ci + 1];
let level = hatch::quantize(val, lo, hi, n_levels);
let pattern = h.ramp[level];
let px_l = xscale.project(x_lo);
let px_r = xscale.project(x_hi);
let py_lo = yscale.project(y_lo);
let py_hi = yscale.project(y_hi);
let x = px_l.min(px_r);
let y = py_lo.min(py_hi);
let w = (px_r - px_l).abs();
let height = (py_hi - py_lo).abs();
let attrs = format!(" fill=\"{}\" stroke=\"none\"", pattern.fill_value());
svg::rect(buf, x, y, w, height, &attrs);
}
}
}
fn draw_hline(
buf: &mut String,
h: &HlineSeries,
theme: &Theme,
px: f64,
pw: f64,
yscale: &Scale,
) {
let y = yscale.project(h.y);
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", h.stroke_width)
.opt_str("stroke-dasharray", h.stroke.dasharray())
.into_string();
svg::line(buf, px, y, px + pw, y, &attrs);
}
fn draw_vline(
buf: &mut String,
v: &VlineSeries,
theme: &Theme,
py: f64,
ph: f64,
xscale: &Scale,
) {
let x = xscale.project(v.x);
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", v.stroke_width)
.opt_str("stroke-dasharray", v.stroke.dasharray())
.into_string();
svg::line(buf, x, py, x, py + ph, &attrs);
}
fn draw_polygon(
buf: &mut String,
p: &PolygonSeries,
_index: usize,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
if p.xs.is_empty() {
return;
}
let sw = p.stroke_width.unwrap_or(theme.series_stroke_width);
let points: Vec<(f64, f64)> = p
.xs
.iter()
.zip(p.ys.iter())
.map(|(x, y)| project_to_pixel(fig, xscale, yscale, *x, *y))
.collect();
let fill = match p.hatch {
None => "none".to_string(),
Some(h) => h.fill_value(),
};
let attrs = svg::Attrs::new()
.str("fill", &fill)
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.opt_str("stroke-dasharray", p.stroke.dasharray())
.str("stroke-linejoin", "round")
.into_string();
svg::polygon(buf, &points, &attrs);
}
fn draw_errorbar(
buf: &mut String,
e: &ErrorBarSeries,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let sw = e.stroke_width.unwrap_or(theme.series_stroke_width);
let cap = e.cap_width;
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.into_string();
for (i, (x, y)) in e.xs.iter().zip(e.ys.iter()).enumerate() {
if let Some(yerr) = &e.yerr {
if let Some(err) = yerr.get(i) {
let (lo, hi) = err_bounds(*err);
let (px_pt, py_lo) = project_to_pixel(fig, xscale, yscale, *x, *y - lo);
let (_, py_hi) = project_to_pixel(fig, xscale, yscale, *x, *y + hi);
svg::line(buf, px_pt, py_lo, px_pt, py_hi, &attrs);
svg::line(buf, px_pt - cap / 2.0, py_lo, px_pt + cap / 2.0, py_lo, &attrs);
svg::line(buf, px_pt - cap / 2.0, py_hi, px_pt + cap / 2.0, py_hi, &attrs);
}
}
if let Some(xerr) = &e.xerr {
if let Some(err) = xerr.get(i) {
let (lo, hi) = err_bounds(*err);
let (px_lo, py_pt) = project_to_pixel(fig, xscale, yscale, *x - lo, *y);
let (px_hi, _) = project_to_pixel(fig, xscale, yscale, *x + hi, *y);
svg::line(buf, px_lo, py_pt, px_hi, py_pt, &attrs);
svg::line(buf, px_lo, py_pt - cap / 2.0, px_lo, py_pt + cap / 2.0, &attrs);
svg::line(buf, px_hi, py_pt - cap / 2.0, px_hi, py_pt + cap / 2.0, &attrs);
}
}
if let Some(marker) = e.marker {
let size = e.marker_size.unwrap_or(theme.marker_size);
let (cx, cy) = project_to_pixel(fig, xscale, yscale, *x, *y);
markers::draw(buf, marker, cx, cy, size, sw);
}
}
}
fn draw_boxplot(
buf: &mut String,
b: &BoxPlotSeries,
index: usize,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
_all_series: &[Series],
categories: &[String],
) {
let hatch = b.hatch.unwrap_or_else(|| Hatch::cycle(index));
let sw = b.stroke_width.unwrap_or(theme.series_stroke_width);
let slot_w = (xscale.project(1.0) - xscale.project(0.0)).abs();
let box_w = slot_w * b.box_width;
let cap_w = box_w * 0.4;
let box_attrs = format!(
" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\"",
hatch.fill_value(),
theme.foreground,
svg::num(sw)
);
let line_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.into_string();
let median_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw * 1.4)
.into_string();
let whisker_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.str("stroke-dasharray", "4 2")
.into_string();
for (cat, stats) in b.categories.iter().zip(b.stats.iter()) {
let Some(idx) = categories.iter().position(|c| c == cat) else {
continue;
};
let cx = xscale.project(idx as f64);
let y_min = yscale.project(stats.min);
let y_q1 = yscale.project(stats.q1);
let y_med = yscale.project(stats.median);
let y_q3 = yscale.project(stats.q3);
let y_max = yscale.project(stats.max);
let left = cx - box_w / 2.0;
let right = cx + box_w / 2.0;
svg::rect(
buf,
left,
y_q1.min(y_q3),
box_w,
(y_q3 - y_q1).abs(),
&box_attrs,
);
svg::line(buf, left, y_med, right, y_med, &median_attrs);
svg::line(buf, cx, y_q1.min(y_q3), cx, y_max, &whisker_attrs);
svg::line(buf, cx, y_q1.max(y_q3), cx, y_min, &whisker_attrs);
svg::line(buf, cx - cap_w / 2.0, y_max, cx + cap_w / 2.0, y_max, &line_attrs);
svg::line(buf, cx - cap_w / 2.0, y_min, cx + cap_w / 2.0, y_min, &line_attrs);
for o in &stats.outliers {
let y = yscale.project(*o);
markers::draw(buf, Marker::CircleOpen, cx, y, 3.0, sw);
}
}
}
fn draw_stem(
buf: &mut String,
s: &StemSeries,
index: usize,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let sw = s.stroke_width.unwrap_or(theme.series_stroke_width);
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.opt_str("stroke-dasharray", s.stroke.dasharray())
.into_string();
let marker = s.marker.unwrap_or_else(|| Marker::cycle(index));
let size = s.marker_size.unwrap_or(theme.marker_size);
for (x, y) in s.xs.iter().zip(s.ys.iter()) {
let (px_pt, py_pt) = project_to_pixel(fig, xscale, yscale, *x, *y);
let (_, py_base) = project_to_pixel(fig, xscale, yscale, *x, s.baseline);
svg::line(buf, px_pt, py_base, px_pt, py_pt, &attrs);
markers::draw(buf, marker, px_pt, py_pt, size, sw);
}
}
fn draw_quiver(
buf: &mut String,
q: &QuiverSeries,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let sw = q.stroke_width.unwrap_or(theme.series_stroke_width);
let scale = q.scale;
let head = q.head_size;
let line_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.opt_str("stroke-dasharray", q.stroke.dasharray())
.into_string();
let head_attrs = format!(" fill=\"{}\"", theme.foreground);
for (((x, y), u), v) in q
.xs
.iter()
.zip(q.ys.iter())
.zip(q.us.iter())
.zip(q.vs.iter())
{
let (x1, y1) = project_to_pixel(fig, xscale, yscale, *x, *y);
let (x2, y2) = project_to_pixel(fig, xscale, yscale, *x + *u * scale, *y + *v * scale);
let angle = (y2 - y1).atan2(x2 - x1);
let ax1 = x2 - head * (angle - std::f64::consts::PI / 8.0).cos();
let ay1 = y2 - head * (angle - std::f64::consts::PI / 8.0).sin();
let ax2 = x2 - head * (angle + std::f64::consts::PI / 8.0).cos();
let ay2 = y2 - head * (angle + std::f64::consts::PI / 8.0).sin();
svg::line(buf, x1, y1, x2, y2, &line_attrs);
svg::polygon(
buf,
&[(x2, y2), (ax1, ay1), (ax2, ay2)],
&head_attrs,
);
}
}
fn draw_contour(
buf: &mut String,
c: &ContourSeries,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
let sw = c.stroke_width.unwrap_or(theme.series_stroke_width);
let segs = crate::contour::segments(&c.data, &c.x_edges, &c.y_edges, &c.levels, c.origin);
for (level, segments) in segs {
let stroke = if level < 0.0 { Stroke::Dashed } else { c.stroke };
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", sw)
.opt_str("stroke-dasharray", stroke.dasharray())
.into_string();
for ((x1, y1), (x2, y2)) in segments {
let (px1, py1) = project_to_pixel(fig, xscale, yscale, x1, y1);
let (px2, py2) = project_to_pixel(fig, xscale, yscale, x2, y2);
svg::line(buf, px1, py1, px2, py2, &attrs);
}
}
}
fn write_annotations(
buf: &mut String,
fig: &Figure,
theme: &Theme,
xscale: &Scale,
yscale: &Scale,
) {
for ann in &fig.annotations {
match ann {
Annotation::Text {
x,
y,
text,
font_size,
anchor,
halo,
} => {
let (px, py) = project_to_pixel(fig, xscale, yscale, *x, *y);
let fs = font_size.unwrap_or(theme.annotation_font_size);
let mut attrs = svg::Attrs::new()
.num("font-size", fs)
.str("font-family", theme.label_font_family)
.str("fill", theme.foreground)
.str("text-anchor", anchor.as_str());
if *halo {
attrs = attrs
.str("stroke", theme.background)
.num("stroke-width", 3.0)
.str("stroke-linejoin", "round")
.str("paint-order", "stroke");
}
svg::text(buf, px, py, &svg::escape(text), &attrs.into_string());
}
Annotation::Arrow { from, to } => {
let (x1, y1) = project_to_pixel(fig, xscale, yscale, from.0, from.1);
let (x2, y2) = project_to_pixel(fig, xscale, yscale, to.0, to.1);
let line_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", 1.0)
.into_string();
svg::line(buf, x1, y1, x2, y2, &line_attrs);
let head = 6.0;
let angle = (y2 - y1).atan2(x2 - x1);
let ax1 = x2 - head * (angle - std::f64::consts::PI / 8.0).cos();
let ay1 = y2 - head * (angle - std::f64::consts::PI / 8.0).sin();
let ax2 = x2 - head * (angle + std::f64::consts::PI / 8.0).cos();
let ay2 = y2 - head * (angle + std::f64::consts::PI / 8.0).sin();
let head_attrs = format!(" fill=\"{}\"", theme.foreground);
svg::polygon(
buf,
&[(x2, y2), (ax1, ay1), (ax2, ay2)],
&head_attrs,
);
}
}
}
}
fn write_colorbar(
buf: &mut String,
fig: &Figure,
theme: &Theme,
cb: &ColorbarConfig,
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
let heatmap = fig
.series
.iter()
.rev()
.find_map(|s| if let Series::Heatmap(h) = s { Some(h) } else { None });
let ramp: Vec<Hatch> = cb
.ramp
.clone()
.or_else(|| heatmap.map(|h| h.ramp.clone()))
.unwrap_or_else(|| Hatch::DEFAULT_RAMP.to_vec());
let range = cb.range.unwrap_or_else(|| {
heatmap
.map(|h| h.range.unwrap_or_else(|| hatch::extent(&h.data)))
.unwrap_or((0.0, 1.0))
});
let label = cb.label.clone().or_else(|| heatmap.and_then(|h| h.label.clone()));
let bar_w = 16.0;
let gap = 14.0;
let tick_px = 36.0;
let (bar_x, bar_y, bar_h) = match cb.position {
ColorbarPosition::Right => (px + pw + gap, py, ph),
ColorbarPosition::Left => (px - gap - bar_w - tick_px, py, ph),
ColorbarPosition::Bottom => (px, py + ph + 24.0, ph),
ColorbarPosition::Manual(x, y) => (x, y, ph),
};
let n = ramp.len();
let step = bar_h / n as f64;
for (i, pattern) in ramp.iter().enumerate() {
let y = bar_y + bar_h - (i as f64 + 1.0) * step;
let attrs = format!(" fill=\"{}\" stroke=\"none\"", pattern.fill_value());
svg::rect(buf, bar_x, y, bar_w, step, &attrs);
}
let frame_attrs = svg::Attrs::new()
.str("fill", "none")
.str("stroke", theme.foreground)
.num("stroke-width", theme.axis_stroke_width)
.into_string();
svg::rect(buf, bar_x, bar_y, bar_w, bar_h, &frame_attrs);
let count = cb.levels.max(2);
let (lo, hi) = range;
let tick_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", theme.tick_stroke_width)
.into_string();
let label_anchor = match cb.position {
ColorbarPosition::Left => "end",
_ => "start",
};
let label_attrs = svg::Attrs::new()
.num("font-size", theme.tick_label_font_size)
.str("font-family", theme.label_font_family)
.str("text-anchor", label_anchor)
.str("fill", theme.foreground)
.into_string();
for i in 0..count {
let frac = i as f64 / (count - 1) as f64;
let val = lo + frac * (hi - lo);
let y = bar_y + bar_h - frac * bar_h;
let (tx_from, tx_to, lx) = match cb.position {
ColorbarPosition::Left => (bar_x, bar_x - 4.0, bar_x - 6.0),
_ => (bar_x + bar_w, bar_x + bar_w + 4.0, bar_x + bar_w + 6.0),
};
svg::line(buf, tx_from, y, tx_to, y, &tick_attrs);
svg::text(
buf,
lx,
y + theme.tick_label_font_size / 2.0 - 2.0,
&svg::escape(&ticks::format(val)),
&label_attrs,
);
}
if let Some(text) = label {
let (lx, ly, rotate) = match cb.position {
ColorbarPosition::Right => (bar_x + bar_w + tick_px + 8.0, bar_y + bar_h / 2.0, true),
ColorbarPosition::Left => (bar_x - 8.0, bar_y + bar_h / 2.0, true),
_ => (bar_x + bar_w / 2.0, bar_y + bar_h + 18.0, false),
};
let mut attrs = svg::Attrs::new()
.num("font-size", theme.axis_label_font_size)
.str("font-family", theme.label_font_family)
.str("text-anchor", "middle")
.str("fill", theme.foreground)
.str("font-style", "italic");
if rotate {
let transform = format!("rotate(-90 {} {})", svg::num(lx), svg::num(ly));
attrs = attrs.str("transform", &transform);
}
svg::text(buf, lx, ly, &svg::escape(&text), &attrs.into_string());
}
}
fn write_axes(
buf: &mut String,
fig: &Figure,
theme: &Theme,
px: f64,
py: f64,
pw: f64,
ph: f64,
xscale: &Scale,
yscale: &Scale,
categorical: bool,
categories: &[String],
) {
if theme.frame {
let attrs = svg::Attrs::new()
.str("fill", "none")
.str("stroke", theme.foreground)
.num("stroke-width", theme.frame_stroke_width)
.into_string();
svg::rect(buf, px, py, pw, ph, &attrs);
} else {
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", theme.axis_stroke_width)
.into_string();
svg::line(buf, px, py + ph, px + pw, py + ph, &attrs);
svg::line(buf, px, py, px, py + ph, &attrs);
}
write_x_ticks(buf, fig, theme, px, py, pw, ph, xscale, categorical, categories);
write_y_ticks(buf, fig, theme, px, yscale);
}
fn write_x_ticks(
buf: &mut String,
fig: &Figure,
theme: &Theme,
_px: f64,
py: f64,
_pw: f64,
ph: f64,
xscale: &Scale,
categorical: bool,
categories: &[String],
) {
let (values, labels): (Vec<f64>, Vec<String>) = if categorical {
let v: Vec<f64> = (0..categories.len()).map(|i| i as f64).collect();
let l: Vec<String> = categories.to_vec();
(v, l)
} else {
let v = tick_values(fig.xscale, xscale.domain());
let l: Vec<String> = v.iter().map(|x| ticks::format(*x)).collect();
(v, l)
};
let len = theme.tick_length;
let (y0, y1, ly) = match theme.tick_direction {
TickDirection::In => (py + ph, py + ph - len, py + ph + len + 2.0),
TickDirection::Out => (py + ph, py + ph + len, py + ph + len + 2.0),
TickDirection::Both => (py + ph - len, py + ph + len, py + ph + len + 2.0),
};
let tick_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", theme.tick_stroke_width)
.into_string();
let label_attrs = svg::Attrs::new()
.num("font-size", theme.tick_label_font_size)
.str("font-family", theme.label_font_family)
.str("text-anchor", "middle")
.str("fill", theme.foreground)
.into_string();
for (v, label) in values.iter().zip(labels.iter()) {
let x = xscale.project(*v);
svg::line(buf, x, y0, x, y1, &tick_attrs);
svg::text(
buf,
x,
ly + theme.tick_label_font_size - 2.0,
&svg::escape(label),
&label_attrs,
);
}
}
fn write_y_ticks(buf: &mut String, fig: &Figure, theme: &Theme, px: f64, yscale: &Scale) {
let values = tick_values(fig.yscale, yscale.domain());
let len = theme.tick_length;
let (x0, x1, lx) = match theme.tick_direction {
TickDirection::In => (px, px + len, px - 4.0),
TickDirection::Out => (px, px - len, px - len - 4.0),
TickDirection::Both => (px - len, px + len, px - len - 4.0),
};
let tick_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", theme.tick_stroke_width)
.into_string();
let label_attrs = svg::Attrs::new()
.num("font-size", theme.tick_label_font_size)
.str("font-family", theme.label_font_family)
.str("text-anchor", "end")
.str("fill", theme.foreground)
.into_string();
for v in values {
let y = yscale.project(v);
svg::line(buf, x0, y, x1, y, &tick_attrs);
svg::text(
buf,
lx,
y + theme.tick_label_font_size / 2.0 - 2.0,
&svg::escape(&ticks::format(v)),
&label_attrs,
);
}
}
fn write_axis_labels(
buf: &mut String,
fig: &Figure,
theme: &Theme,
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
let xlabel_gap = if fig.title_block.is_some() { 16.0 } else { 22.0 };
if let Some(label) = &fig.xlabel {
let attrs = svg::Attrs::new()
.num("font-size", theme.axis_label_font_size)
.str("font-family", theme.label_font_family)
.str("text-anchor", "middle")
.str("fill", theme.foreground)
.str("font-style", "italic")
.into_string();
svg::text(
buf,
px + pw / 2.0,
py + ph + theme.tick_length + theme.tick_label_font_size + xlabel_gap,
&svg::escape(label),
&attrs,
);
}
if let Some(label) = &fig.ylabel {
let lx = px - 44.0;
let ly = py + ph / 2.0;
let transform = format!("rotate(-90 {} {})", svg::num(lx), svg::num(ly));
let attrs = svg::Attrs::new()
.num("font-size", theme.axis_label_font_size)
.str("font-family", theme.label_font_family)
.str("text-anchor", "middle")
.str("fill", theme.foreground)
.str("font-style", "italic")
.str("transform", &transform)
.into_string();
svg::text(buf, lx, ly, &svg::escape(label), &attrs);
}
let _ = TitleCase::None;
}
fn write_legend(
buf: &mut String,
fig: &Figure,
theme: &Theme,
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
let Some(legend) = &fig.legend else {
return;
};
let entries = legend_entries(&fig.series);
if entries.is_empty() && legend.title.is_none() {
return;
}
let row_h = theme.legend_font_size + 6.0;
let swatch_w = 28.0;
let label_pad = 8.0;
let vpad = 6.0;
let extra_title = if legend.title.is_some() { row_h + 2.0 } else { 0.0 };
let max_label_chars = entries
.iter()
.map(|(label, _, _)| label.chars().count())
.max()
.unwrap_or(6);
let box_h = entries.len() as f64 * row_h + 2.0 * vpad + extra_title;
let box_w = swatch_w + label_pad + 80f64.max(max_label_chars as f64 * theme.legend_font_size * 0.55) + 12.0;
let (bx, by) = match legend.position {
LegendPosition::TopRight => (px + pw - box_w - 8.0, py + 8.0),
LegendPosition::TopLeft => (px + 8.0, py + 8.0),
LegendPosition::BottomRight => (px + pw - box_w - 8.0, py + ph - box_h - 8.0),
LegendPosition::BottomLeft => (px + 8.0, py + ph - box_h - 8.0),
LegendPosition::Manual(x, y) => (x, y),
};
if theme.legend_frame {
let attrs = svg::Attrs::new()
.str("fill", theme.background)
.str("stroke", theme.foreground)
.num("stroke-width", theme.legend_stroke_width)
.into_string();
svg::rect(buf, bx, by, box_w, box_h, &attrs);
}
if let Some(title) = &legend.title {
let attrs = svg::Attrs::new()
.num("font-size", theme.legend_font_size)
.str("font-family", theme.font_family)
.str("font-weight", "bold")
.str("fill", theme.foreground)
.into_string();
svg::text(buf, bx + 8.0, by + vpad + row_h - 3.0, &svg::escape(title), &attrs);
}
let row_start_y = by + vpad + extra_title;
let label_attrs = svg::Attrs::new()
.num("font-size", theme.legend_font_size)
.str("font-family", theme.font_family)
.str("fill", theme.foreground)
.into_string();
for (i, (label, kind, style)) in entries.iter().enumerate() {
let y = row_start_y + i as f64 * row_h;
let swatch_y = y + row_h / 2.0 - 4.0;
let swatch_x = bx + 8.0;
legend_swatch(buf, theme, *kind, style, swatch_x, swatch_y, swatch_w);
svg::text(
buf,
swatch_x + swatch_w + label_pad,
y + row_h - 3.0,
&svg::escape(label),
&label_attrs,
);
}
}
#[derive(Debug, Clone, Copy)]
enum LegendKind {
Line,
Scatter,
Bar,
Area,
}
#[derive(Debug, Clone, Copy)]
struct LegendStyle {
stroke: Option<Stroke>,
marker: Option<Marker>,
hatch: Option<Hatch>,
}
fn legend_entries(series: &[Series]) -> Vec<(String, LegendKind, LegendStyle)> {
let mut out = Vec::new();
for (i, s) in series.iter().enumerate() {
match s {
Series::Line(l) => {
if let Some(label) = &l.label {
let stroke = l.stroke.unwrap_or_else(|| Stroke::cycle(i));
let marker = if l.markers {
Some(l.marker.unwrap_or_else(|| Marker::cycle(i)))
} else {
None
};
out.push((
label.clone(),
LegendKind::Line,
LegendStyle {
stroke: Some(stroke),
marker,
hatch: None,
},
));
}
}
Series::Scatter(s) => {
if let Some(label) = &s.label {
let marker = s.marker.unwrap_or_else(|| Marker::cycle(i));
out.push((
label.clone(),
LegendKind::Scatter,
LegendStyle {
stroke: None,
marker: Some(marker),
hatch: None,
},
));
}
}
Series::Bar(b) => {
if let Some(label) = &b.label {
let hatch = b.hatch.unwrap_or_else(|| Hatch::cycle(i));
out.push((
label.clone(),
LegendKind::Bar,
LegendStyle {
stroke: None,
marker: None,
hatch: Some(hatch),
},
));
}
}
Series::Area(a) => {
if let Some(label) = &a.label {
let hatch = a.hatch.unwrap_or_else(|| Hatch::cycle(i));
out.push((
label.clone(),
LegendKind::Area,
LegendStyle {
stroke: Some(a.stroke.unwrap_or(Stroke::Solid)),
marker: None,
hatch: Some(hatch),
},
));
}
}
Series::Histogram(h) => {
if let Some(label) = &h.label {
let hatch = h.hatch.unwrap_or_else(|| Hatch::cycle(i));
out.push((
label.clone(),
LegendKind::Bar,
LegendStyle {
stroke: None,
marker: None,
hatch: Some(hatch),
},
));
}
}
Series::Hline(h) => {
if let Some(label) = &h.label {
out.push((
label.clone(),
LegendKind::Line,
LegendStyle {
stroke: Some(h.stroke),
marker: None,
hatch: None,
},
));
}
}
Series::Vline(v) => {
if let Some(label) = &v.label {
out.push((
label.clone(),
LegendKind::Line,
LegendStyle {
stroke: Some(v.stroke),
marker: None,
hatch: None,
},
));
}
}
Series::Polygon(p) => {
if let Some(label) = &p.label {
let style = LegendStyle {
stroke: Some(p.stroke),
marker: None,
hatch: p.hatch,
};
let kind = if p.hatch.is_some() {
LegendKind::Area
} else {
LegendKind::Line
};
out.push((label.clone(), kind, style));
}
}
Series::ErrorBar(e) => {
if let Some(label) = &e.label {
out.push((
label.clone(),
LegendKind::Scatter,
LegendStyle {
stroke: None,
marker: Some(e.marker.unwrap_or(Marker::CircleFilled)),
hatch: None,
},
));
}
}
Series::BoxPlot(b) => {
if let Some(label) = &b.label {
let hatch = b.hatch.unwrap_or_else(|| Hatch::cycle(i));
out.push((
label.clone(),
LegendKind::Bar,
LegendStyle {
stroke: None,
marker: None,
hatch: Some(hatch),
},
));
}
}
Series::Stem(s) => {
if let Some(label) = &s.label {
let marker = s.marker.unwrap_or_else(|| Marker::cycle(i));
out.push((
label.clone(),
LegendKind::Line,
LegendStyle {
stroke: Some(s.stroke),
marker: Some(marker),
hatch: None,
},
));
}
}
Series::Quiver(q) => {
if let Some(label) = &q.label {
out.push((
label.clone(),
LegendKind::Line,
LegendStyle {
stroke: Some(q.stroke),
marker: None,
hatch: None,
},
));
}
}
Series::Contour(c) => {
if let Some(label) = &c.label {
out.push((
label.clone(),
LegendKind::Line,
LegendStyle {
stroke: Some(c.stroke),
marker: None,
hatch: None,
},
));
}
}
Series::Heatmap(_) => {}
}
}
out
}
fn legend_swatch(
buf: &mut String,
theme: &Theme,
kind: LegendKind,
style: &LegendStyle,
x: f64,
y: f64,
w: f64,
) {
let cy = y + 4.0;
match kind {
LegendKind::Line => {
let attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", theme.series_stroke_width)
.opt_str(
"stroke-dasharray",
style.stroke.and_then(|s| s.dasharray()),
)
.into_string();
svg::line(buf, x, cy, x + w, cy, &attrs);
if let Some(m) = style.marker {
markers::draw(
buf,
m,
x + w / 2.0,
cy,
theme.marker_size - 1.0,
theme.marker_stroke_width,
);
}
}
LegendKind::Scatter => {
if let Some(m) = style.marker {
markers::draw(
buf,
m,
x + w / 2.0,
cy,
theme.marker_size,
theme.marker_stroke_width,
);
}
}
LegendKind::Bar => {
let hatch = style.hatch.unwrap_or(Hatch::Crosshatch);
let attrs = format!(
" fill=\"{}\" stroke=\"{}\" stroke-width=\"1\"",
hatch.fill_value(),
theme.foreground
);
svg::rect(buf, x, y - 3.0, w, 12.0, &attrs);
}
LegendKind::Area => {
let hatch = style.hatch.unwrap_or(Hatch::Crosshatch);
let fill_attrs = format!(" fill=\"{}\" stroke=\"none\"", hatch.fill_value());
svg::rect(buf, x, y - 3.0, w, 12.0, &fill_attrs);
let line_attrs = svg::Attrs::new()
.str("stroke", theme.foreground)
.num("stroke-width", 1.0)
.opt_str(
"stroke-dasharray",
style.stroke.and_then(|s| s.dasharray()),
)
.into_string();
svg::line(buf, x, y - 3.0, x + w, y - 3.0, &line_attrs);
}
}
}
fn write_title_block(
buf: &mut String,
tb: &TitleBlock,
theme: &Theme,
fig_w: f64,
fig_h: f64,
) {
let w = tb.width;
let h = tb.height;
let (bx, by) = match tb.position {
TitleBlockPosition::BottomRight => (
fig_w - w - (theme.border_inset + 8.0),
fig_h - h - (theme.border_inset + 8.0),
),
TitleBlockPosition::BottomLeft => (
theme.border_inset + 8.0,
fig_h - h - (theme.border_inset + 8.0),
),
};
let left_w = w * 0.66;
let col_mid = bx + left_w;
let row1_y = by + h * 0.35;
let row2_y = by + h * 0.70;
let sub_col_w = left_w / 2.0;
let sub_mid = bx + sub_col_w;
let rev_mid = col_mid + (w - left_w) * 0.55;
let frame_attrs = format!(
" fill=\"{}\" stroke=\"{}\" stroke-width=\"1.2\"",
theme.background, theme.foreground
);
svg::rect(buf, bx, by, w, h, &frame_attrs);
let rule_attrs = format!(
" stroke=\"{}\" stroke-width=\"0.8\"",
theme.foreground
);
svg::line(buf, bx, row1_y, bx + w, row1_y, &rule_attrs);
svg::line(buf, bx, row2_y, bx + w, row2_y, &rule_attrs);
svg::line(buf, col_mid, by, col_mid, by + h, &rule_attrs);
svg::line(buf, sub_mid, row2_y, sub_mid, by + h, &rule_attrs);
svg::line(buf, rev_mid, row2_y, rev_mid, by + h, &rule_attrs);
let small = format!(
" font-size=\"8\" font-family=\"{}\" fill=\"{}\"",
theme.label_font_family, theme.foreground
);
let normal = format!(
" font-size=\"10\" font-family=\"{}\" fill=\"{}\"",
theme.label_font_family, theme.foreground
);
let bold = format!(
" font-size=\"10\" font-family=\"{}\" fill=\"{}\" font-weight=\"bold\"",
theme.label_font_family, theme.foreground
);
let dash = "—".to_string();
let cell = |buf: &mut String, cx: f64, cy: f64, label: &str, value: &Option<String>, value_attrs: &str| {
svg::text(buf, cx + 6.0, cy + 10.0, &svg::escape(label), &small);
let v = value.as_ref().unwrap_or(&dash);
svg::text(buf, cx + 6.0, cy + 22.0, &svg::escape(v), value_attrs);
};
cell(buf, bx, by, "PROJECT", &tb.project, &normal);
cell(buf, col_mid, by, "DRAWN", &tb.drawn_by, &normal);
cell(buf, bx, row1_y, "TITLE", &tb.title, &bold);
cell(buf, col_mid, row1_y, "CHECKED", &tb.checked_by, &normal);
cell(buf, bx, row2_y, "DATE", &tb.date, &normal);
cell(buf, sub_mid, row2_y, "SCALE", &tb.scale, &normal);
cell(buf, col_mid, row2_y, "SHEET", &tb.sheet, &normal);
cell(buf, rev_mid, row2_y, "REV", &tb.rev, &bold);
}