use crate::render::alluvial_order::optimize_sankey_alluvial_order;
use crate::render::annotations::{add_reference_lines, add_shaded_regions, add_text_annotations};
use crate::render::axis::{add_axes_and_grid, add_labels_and_title, add_y2_axis};
use crate::render::layout::{ComputedLayout, Layout, TickFormat};
use crate::render::palette::Palette;
use crate::render::plots::Plot;
use crate::render::render_utils::{self, linear_regression, pearson_corr, percentile};
use crate::render::theme::Theme;
use std::collections::HashMap;
use std::fmt::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static PLOT_CLIP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[inline]
fn next_clip_id() -> String {
let n = PLOT_CLIP_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("kuva-clip-{n}")
}
#[inline(always)]
fn round2(v: f64) -> f64 {
(v * 100.0).round() * 0.01
}
#[inline]
fn tooltip(
show: bool,
custom_labels: &Option<Vec<String>>,
i: usize,
auto_fn: impl FnOnce() -> String,
) -> Option<String> {
if let Some(ref labels) = custom_labels {
labels.get(i).cloned()
} else if show {
Some(auto_fn())
} else {
None
}
}
use crate::plot::band::BandPlot;
use crate::plot::bar::BarPlot;
use crate::plot::bump::{BumpPlot, CurveStyle};
use crate::plot::calendar::{
dow_mon0, from_jd, period_grid_pos, period_max_cols, to_jd, CalendarAgg, CalendarPlot,
WeekStart,
};
use crate::plot::candlestick::{CandleDataPoint, CandlestickPlot};
use crate::plot::chord::ChordPlot;
use crate::plot::clustermap::{Clustermap, ClustermapNorm};
use crate::plot::contour::ContourPlot;
use crate::plot::density::DensityPlot;
use crate::plot::diceplot::DicePlot;
use crate::plot::dotplot::DotPlot;
use crate::plot::forest::ForestPlot;
use crate::plot::funnel::{FunnelColorMode, FunnelOrientation, FunnelPlot, FunnelStage};
use crate::plot::gantt::{GanttDisplayRow, GanttPlot};
use crate::plot::hexbin::{HexbinPlot, ZReduce};
use crate::plot::histogram::Histogram;
use crate::plot::horizon::HorizonPlot;
use crate::plot::legend_plot::LegendPlot;
use crate::plot::line::LinePlot;
use crate::plot::manhattan::ManhattanPlot;
use crate::plot::mosaic::MosaicPlot;
use crate::plot::network::{NetworkPlot, NodeShape};
use crate::plot::parallel::{ParallelPlot, ParallelRow};
use crate::plot::phylo::{PhyloTree, TreeBranchStyle, TreeOrientation};
use crate::plot::pie::PieLabelPosition;
use crate::plot::polar::{PolarMode, PolarPlot};
use crate::plot::pr::PrPlot;
use crate::plot::pyramid::{PopulationPyramid, PyramidMode};
use crate::plot::raincloud::RaincloudPlot;
use crate::plot::ridgeline::RidgelinePlot;
use crate::plot::roc::RocPlot;
use crate::plot::rose::{RoseEncoding, RoseMode, RosePlot};
use crate::plot::sankey::{SankeyLinkColor, SankeyNodeColoring, SankeyNodeOrder, SankeyPlot};
use crate::plot::scatter::{MarkerShape, ScatterPlot, TrendLine};
use crate::plot::scatter3d::Scatter3DPlot;
use crate::plot::slope::SlopePlot;
use crate::plot::stacked_area::StackedAreaPlot;
use crate::plot::strip::{StripPlot, StripStyle};
use crate::plot::sunburst::{SunburstColorMode, SunburstPlot};
use crate::plot::surface3d::Surface3DPlot;
use crate::plot::synteny::{Strand, SyntenyPlot};
use crate::plot::ternary::TernaryPlot;
use crate::plot::text::{TextAlign, TextPlot};
use crate::plot::treemap::{TreemapColorMode, TreemapLayout, TreemapNode, TreemapPlot};
use crate::plot::upset::UpSetPlot;
use crate::plot::venn::VennPlot;
use crate::plot::volcano::{LabelStyle, VolcanoPlot};
use crate::plot::waffle::{CellShape, FillOrder, WaffleCategory, WafflePlot};
use crate::plot::waterfall::{WaterfallKind, WaterfallPlot};
use crate::plot::{
BoxPlot, BrickAnchor, BrickPlot, Heatmap, Histogram2D, PiePlot, SeriesPlot, SeriesStyle,
ViolinPlot,
};
use crate::plot::legend::{ColorBarInfo, LegendEntry, LegendGroup, LegendPosition, LegendShape};
use crate::plot::Legend;
use crate::render::color::Color;
#[derive(Debug, Clone)]
pub struct TextSpan {
pub text: String,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl TextSpan {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
bold: false,
italic: false,
underline: false,
}
}
}
#[derive(Debug)]
pub struct PathData {
pub d: String,
pub fill: Option<Color>,
pub stroke: Color,
pub stroke_width: f64,
pub opacity: Option<f64>,
pub stroke_dasharray: Option<String>,
}
#[derive(Debug)]
pub enum Primitive {
Circle {
cx: f64,
cy: f64,
r: f64,
fill: Color,
fill_opacity: Option<f64>,
stroke: Option<Color>,
stroke_width: Option<f64>,
},
Text {
x: f64,
y: f64,
content: String,
size: u32,
anchor: TextAnchor,
rotate: Option<f64>,
bold: bool,
color: Option<Color>,
},
RichText {
x: f64,
y: f64,
spans: Vec<TextSpan>,
size: u32,
anchor: TextAnchor,
color: Option<Color>,
},
Line {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
stroke: Color,
stroke_width: f64,
stroke_dasharray: Option<String>,
},
Path(Box<PathData>),
Rect {
x: f64,
y: f64,
width: f64,
height: f64,
fill: Color,
stroke: Option<Color>,
stroke_width: Option<f64>,
opacity: Option<f64>,
},
CircleBatch {
cx: Vec<f64>,
cy: Vec<f64>,
r: f64,
fill: Color,
fill_opacity: Option<f64>,
stroke: Option<Color>,
stroke_width: Option<f64>,
},
RectBatch {
x: Vec<f64>,
y: Vec<f64>,
w: Vec<f64>,
h: Vec<f64>,
fills: Vec<Color>,
},
GroupStart {
transform: Option<String>,
title: Option<String>,
extra_attrs: Option<String>,
},
GroupEnd,
ClipStart {
x: f64,
y: f64,
width: f64,
height: f64,
id: String,
},
ClipEnd,
}
#[derive(Debug, Clone, Copy)]
pub enum TextAnchor {
Start,
Middle,
End,
}
#[derive(Debug)]
pub struct AxisMeta {
pub x_min: f64,
pub x_max: f64,
pub y_min: f64,
pub y_max: f64,
pub plot_left: f64,
pub plot_top: f64,
pub plot_right: f64,
pub plot_bottom: f64,
pub log_x: bool,
pub log_y: bool,
}
#[derive(Debug)]
pub struct Scene {
pub width: f64,
pub height: f64,
pub background_color: Option<String>,
pub text_color: Option<String>,
pub font_family: Option<String>,
pub elements: Vec<Primitive>,
pub defs: Vec<String>,
pub has_tooltips: bool,
pub interactive: bool,
pub axis_meta: Option<AxisMeta>,
pub scripts: Vec<String>,
}
impl Scene {
pub fn new(width: f64, height: f64) -> Self {
Self {
width,
height,
background_color: Some("white".to_string()),
text_color: None,
font_family: None,
elements: Vec::new(),
defs: Vec::new(),
has_tooltips: false,
interactive: false,
axis_meta: None,
scripts: Vec::new(),
}
}
pub fn with_capacity(width: f64, height: f64, capacity: usize) -> Self {
Self {
width,
height,
background_color: Some("white".to_string()),
text_color: None,
font_family: None,
elements: Vec::with_capacity(capacity),
defs: Vec::new(),
has_tooltips: false,
interactive: false,
axis_meta: None,
scripts: Vec::new(),
}
}
pub fn with_background(mut self, color: Option<&str>) -> Self {
self.background_color = color.map(|c| c.to_string());
self
}
pub fn add(&mut self, p: Primitive) {
if let Primitive::GroupStart { title: Some(_), .. } = &p {
self.has_tooltips = true;
}
self.elements.push(p);
}
}
fn apply_theme(scene: &mut Scene, theme: &Theme) {
scene.background_color = Some(theme.background.clone());
scene.text_color = Some(theme.text_color.clone());
}
pub fn build_path(points: &[(f64, f64)]) -> String {
let mut path = String::with_capacity(points.len() * 16);
let mut rb = ryu::Buffer::new();
for (i, &(x, y)) in points.iter().enumerate() {
path.push(if i == 0 { 'M' } else { 'L' });
path.push(' ');
path.push_str(rb.format(round2(x)));
path.push(' ');
path.push_str(rb.format(round2(y)));
path.push(' ');
}
path
}
pub fn build_step_path(points: &[(f64, f64)]) -> String {
let mut path = String::with_capacity(points.len() * 24);
let mut rb = ryu::Buffer::new();
for (i, &(x, y)) in points.iter().enumerate() {
if i == 0 {
path.push_str("M ");
path.push_str(rb.format(round2(x)));
path.push(' ');
path.push_str(rb.format(round2(y)));
path.push(' ');
} else {
let prev_y = points[i - 1].1;
path.push_str("L ");
path.push_str(rb.format(round2(x)));
path.push(' ');
path.push_str(rb.format(round2(prev_y)));
path.push_str(" L ");
path.push_str(rb.format(round2(x)));
path.push(' ');
path.push_str(rb.format(round2(y)));
path.push(' ');
}
}
path
}
#[allow(clippy::too_many_arguments)]
fn draw_marker(
scene: &mut Scene,
marker: MarkerShape,
cx: f64,
cy: f64,
size: f64,
fill: &str,
fill_opacity: Option<f64>,
stroke: Option<Color>,
stroke_width: Option<f64>,
) {
match marker {
MarkerShape::Circle => {
scene.add(Primitive::Circle {
cx,
cy,
r: size,
fill: fill.into(),
fill_opacity,
stroke,
stroke_width,
});
}
MarkerShape::Square => {
scene.add(Primitive::Rect {
x: cx - size,
y: cy - size,
width: size * 2.0,
height: size * 2.0,
fill: fill.into(),
stroke: None,
stroke_width: None,
opacity: None,
});
}
MarkerShape::Triangle => {
let h = size * 1.7;
let mut d = String::with_capacity(64);
let mut rb = ryu::Buffer::new();
d.push('M');
d.push_str(rb.format(round2(cx)));
d.push(',');
d.push_str(rb.format(round2(cy - h * 0.6)));
d.push_str(" L");
d.push_str(rb.format(round2(cx - size)));
d.push(',');
d.push_str(rb.format(round2(cy + h * 0.4)));
d.push_str(" L");
d.push_str(rb.format(round2(cx + size)));
d.push(',');
d.push_str(rb.format(round2(cy + h * 0.4)));
d.push_str(" Z");
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(fill.into()),
stroke: fill.into(),
stroke_width: 0.5,
opacity: None,
stroke_dasharray: None,
})));
}
MarkerShape::Diamond => {
let s = size * 1.3;
let mut d = String::with_capacity(80);
let mut rb = ryu::Buffer::new();
d.push('M');
d.push_str(rb.format(round2(cx)));
d.push(',');
d.push_str(rb.format(round2(cy - s)));
d.push_str(" L");
d.push_str(rb.format(round2(cx + s)));
d.push(',');
d.push_str(rb.format(round2(cy)));
d.push_str(" L");
d.push_str(rb.format(round2(cx)));
d.push(',');
d.push_str(rb.format(round2(cy + s)));
d.push_str(" L");
d.push_str(rb.format(round2(cx - s)));
d.push(',');
d.push_str(rb.format(round2(cy)));
d.push_str(" Z");
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(fill.into()),
stroke: fill.into(),
stroke_width: 0.5,
opacity: None,
stroke_dasharray: None,
})));
}
MarkerShape::Cross => {
let s = size * 0.9;
scene.add(Primitive::Line {
x1: cx - s,
y1: cy - s,
x2: cx + s,
y2: cy + s,
stroke: fill.into(),
stroke_width: 1.5,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx - s,
y1: cy + s,
x2: cx + s,
y2: cy - s,
stroke: fill.into(),
stroke_width: 1.5,
stroke_dasharray: None,
});
}
MarkerShape::Plus => {
let s = size * 0.9;
scene.add(Primitive::Line {
x1: cx - s,
y1: cy,
x2: cx + s,
y2: cy,
stroke: fill.into(),
stroke_width: 1.5,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx,
y1: cy - s,
x2: cx,
y2: cy + s,
stroke: fill.into(),
stroke_width: 1.5,
stroke_dasharray: None,
});
}
}
}
fn add_band(band: &BandPlot, scene: &mut Scene, computed: &ComputedLayout) {
if band.x.len() < 2 {
return;
}
let cap = (band.x.len() * 2 + 1) * 16;
let mut path = String::with_capacity(cap);
let mut rb = ryu::Buffer::new();
for (i, (&x, &y)) in band.x.iter().zip(band.y_upper.iter()).enumerate() {
let sx = computed.map_x(x);
let sy = computed.map_y(y);
path.push(if i == 0 { 'M' } else { 'L' });
path.push(' ');
path.push_str(rb.format(round2(sx)));
path.push(' ');
path.push_str(rb.format(round2(sy)));
path.push(' ');
}
for (&x, &y) in band.x.iter().zip(band.y_lower.iter()).rev() {
let sx = computed.map_x(x);
let sy = computed.map_y(y);
path.push_str("L ");
path.push_str(rb.format(round2(sx)));
path.push(' ');
path.push_str(rb.format(round2(sy)));
path.push(' ');
}
path.push('Z');
scene.add(Primitive::Path(Box::new(PathData {
d: path,
fill: Some(Color::from(&band.color)),
stroke: "none".into(),
stroke_width: 0.0,
opacity: Some(band.opacity),
stroke_dasharray: None,
})));
}
fn add_scatter(scatter: &ScatterPlot, scene: &mut Scene, computed: &ComputedLayout) {
if let Some(ref band) = scatter.band {
add_band(band, scene, computed);
}
let uniform_circles = matches!(scatter.marker, MarkerShape::Circle)
&& scatter.sizes.is_none()
&& scatter.colors.is_none()
&& !scatter.show_tooltips
&& scatter.tooltip_labels.is_none()
&& !scatter
.data
.iter()
.any(|p| p.x_err.is_some() || p.y_err.is_some())
&& !computed.interactive;
let marker_stroke = scatter
.marker_stroke_width
.map(|_| Color::from(scatter.color.as_str()));
if uniform_circles {
let (cx_vec, cy_vec): (Vec<f64>, Vec<f64>) = scatter
.data
.iter()
.map(|point| (computed.map_x(point.x), computed.map_y(point.y)))
.unzip();
scene.add(Primitive::CircleBatch {
cx: cx_vec,
cy: cy_vec,
r: scatter.size,
fill: Color::from(scatter.color.as_str()),
fill_opacity: scatter.marker_opacity,
stroke: marker_stroke,
stroke_width: scatter.marker_stroke_width,
});
} else {
for (i, point) in scatter.data.iter().enumerate() {
let size = scatter
.sizes
.as_ref()
.and_then(|s| s.get(i).copied())
.unwrap_or(scatter.size);
let color = scatter
.colors
.as_ref()
.and_then(|c| c.get(i).map(|s| s.as_str()))
.unwrap_or(&scatter.color);
let pt_stroke = scatter.marker_stroke_width.map(|_| Color::from(color));
let tip = tooltip(
scatter.show_tooltips || computed.interactive,
&scatter.tooltip_labels,
i,
|| format!("x={:.2}, y={:.2}", point.x, point.y),
);
let scatter_extra = if computed.interactive {
let group = scatter
.group_name
.as_deref()
.or(scatter.legend_label.as_deref());
let group_attr = group
.map(|g| format!(r#" data-group="{g}""#))
.unwrap_or_default();
Some(format!(
r#"class="tt" data-x="{x}" data-y="{y}"{group_attr}"#,
x = point.x,
y = point.y
))
} else {
None
};
if tip.is_some() || scatter_extra.is_some() {
let title = tip.clone();
scene.add(Primitive::GroupStart {
transform: None,
title,
extra_attrs: scatter_extra,
});
}
draw_marker(
scene,
scatter.marker,
computed.map_x(point.x),
computed.map_y(point.y),
size,
color,
scatter.marker_opacity,
pt_stroke,
scatter.marker_stroke_width,
);
if let Some((neg, pos)) = point.x_err {
let cy = computed.map_y(point.y);
let cx_low = computed.map_x(point.x - neg);
let cx_high = computed.map_x(point.x + pos);
scene.add(Primitive::Line {
x1: cx_low,
y1: cy,
x2: cx_high,
y2: cy,
stroke: Color::from(&scatter.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx_low,
y1: cy - 5.0,
x2: cx_low,
y2: cy + 5.0,
stroke: Color::from(&scatter.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx_high,
y1: cy - 5.0,
x2: cx_high,
y2: cy + 5.0,
stroke: Color::from(&scatter.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
if let Some((neg, pos)) = point.y_err {
let cx = computed.map_x(point.x);
let cy_low = computed.map_y(point.y - neg);
let cy_high = computed.map_y(point.y + pos);
scene.add(Primitive::Line {
x1: cx,
y1: cy_low,
x2: cx,
y2: cy_high,
stroke: Color::from(&scatter.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx - 5.0,
y1: cy_low,
x2: cx + 5.0,
y2: cy_low,
stroke: Color::from(&scatter.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx - 5.0,
y1: cy_high,
x2: cx + 5.0,
y2: cy_high,
stroke: Color::from(&scatter.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
if tip.is_some() || computed.interactive {
scene.add(Primitive::GroupEnd);
}
}
}
if let Some(trend) = scatter.trend {
match trend {
TrendLine::Linear => {
if let Some((slope, intercept, r)) = linear_regression(&scatter.data) {
let x1 = computed.x_range.0;
let x2 = computed.x_range.1;
let y1 = slope * x1 + intercept;
let y2 = slope * x2 + intercept;
scene.add(Primitive::Line {
x1: computed.map_x(x1),
y1: computed.map_y(y1),
x2: computed.map_x(x2),
y2: computed.map_y(y2),
stroke: Color::from(&scatter.trend_color),
stroke_width: scatter.trend_width,
stroke_dasharray: None,
});
if scatter.show_equation || scatter.show_correlation {
let mut label = String::new();
if scatter.show_equation {
label.push_str(&format!("y = {:.2}x + {:.2}", slope, intercept));
}
if scatter.show_correlation {
if !label.is_empty() {
label.push_str(" ");
}
label.push_str(&format!("r = {:.2}", r));
}
scene.add(Primitive::Text {
x: computed.margin_left + 10.0,
y: computed.margin_top + 20.0,
content: label,
size: computed.body_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
} }
}
}
fn add_line(line: &LinePlot, scene: &mut Scene, computed: &ComputedLayout) {
let interactive_group = computed.interactive && line.legend_label.is_some();
if interactive_group {
let group = line.legend_label.as_deref().unwrap_or("");
scene.add(Primitive::GroupStart {
transform: None,
title: None,
extra_attrs: Some(format!("class=\"tt\" data-group=\"{}\"", group)),
});
}
if let Some(ref band) = line.band {
add_band(band, scene, computed);
}
if line.data.len() >= 2 {
let points: Vec<(f64, f64)> = line
.data
.iter()
.map(|c| (computed.map_x(c.x), computed.map_y(c.y)))
.collect();
let stroke_d = if line.step {
build_step_path(&points)
} else {
build_path(&points)
};
if line.fill {
let baseline_y = computed.map_y(computed.y_range.0.max(0.0));
let first_x = points
.first()
.expect("line fill requires at least one point")
.0;
let last_x = points
.last()
.expect("line fill requires at least one point")
.0;
let fill_d = format!(
"{}L {last_x} {baseline_y} L {first_x} {baseline_y} Z",
stroke_d
);
scene.add(Primitive::Path(Box::new(PathData {
d: fill_d,
fill: Some(Color::from(&line.color)),
stroke: "none".into(),
stroke_width: 0.0,
opacity: Some(line.fill_opacity),
stroke_dasharray: None,
})));
}
scene.add(Primitive::Path(Box::new(PathData {
d: stroke_d,
fill: None,
stroke: Color::from(&line.color),
stroke_width: line.stroke_width,
opacity: None,
stroke_dasharray: line.line_style.dasharray(),
})));
}
for point in &line.data {
if let Some((neg, pos)) = point.x_err {
let cy = computed.map_y(point.y);
let cx_low = computed.map_x(point.x - neg);
let cx_high = computed.map_x(point.x + pos);
scene.add(Primitive::Line {
x1: cx_low,
y1: cy,
x2: cx_high,
y2: cy,
stroke: Color::from(&line.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx_low,
y1: cy - 5.0,
x2: cx_low,
y2: cy + 5.0,
stroke: Color::from(&line.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx_high,
y1: cy - 5.0,
x2: cx_high,
y2: cy + 5.0,
stroke: Color::from(&line.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
if let Some((neg, pos)) = point.y_err {
let cx = computed.map_x(point.x);
let cy_low = computed.map_y(point.y - neg);
let cy_high = computed.map_y(point.y + pos);
scene.add(Primitive::Line {
x1: cx,
y1: cy_low,
x2: cx,
y2: cy_high,
stroke: Color::from(&line.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx - 5.0,
y1: cy_low,
x2: cx + 5.0,
y2: cy_low,
stroke: Color::from(&line.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx - 5.0,
y1: cy_high,
x2: cx + 5.0,
y2: cy_high,
stroke: Color::from(&line.color),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
}
if interactive_group {
scene.add(Primitive::GroupEnd);
}
}
fn add_series(series: &SeriesPlot, scene: &mut Scene, computed: &ComputedLayout) {
let points: Vec<(f64, f64)> = series
.values
.iter()
.enumerate()
.map(|(i, &y)| (computed.map_x(i as f64), computed.map_y(y)))
.collect();
match series.style {
SeriesStyle::Line => {
if points.len() >= 2 {
scene.add(Primitive::Path(Box::new(PathData {
d: build_path(&points),
fill: None,
stroke: Color::from(&series.color),
stroke_width: series.stroke_width,
opacity: None,
stroke_dasharray: None,
})));
}
}
SeriesStyle::Point => {
for (x, y) in points {
scene.add(Primitive::Circle {
cx: x,
cy: y,
r: series.point_radius,
fill: Color::from(&series.color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
SeriesStyle::Both => {
if points.len() >= 2 {
scene.add(Primitive::Path(Box::new(PathData {
d: build_path(&points),
fill: None,
stroke: Color::from(&series.color),
stroke_width: series.stroke_width,
opacity: None,
stroke_dasharray: None,
})));
}
for (x, y) in points {
scene.add(Primitive::Circle {
cx: x,
cy: y,
r: series.point_radius,
fill: Color::from(&series.color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
}
}
fn add_bar(bar: &BarPlot, scene: &mut Scene, computed: &ComputedLayout) {
let mut flat_i: usize = 0;
for (i, group) in bar.groups.iter().enumerate() {
let group_x = i as f64 + 1.0;
let total_width = bar.width;
if bar.stacked {
let mut y_accum = 0.0;
for (j, bar_val) in group.bars.iter().enumerate() {
let x0 = computed.map_x(group_x - total_width / 2.0);
let x1 = computed.map_x(group_x + total_width / 2.0);
let y0 = computed.map_y(y_accum);
let y1 = computed.map_y(y_accum + bar_val.value);
let series_label = bar
.legend_label
.as_ref()
.and_then(|ll| ll.get(j))
.map(|s| s.as_str())
.unwrap_or(&group.label);
let tip = tooltip(
bar.show_tooltips || computed.interactive,
&bar.tooltip_labels,
flat_i,
|| format!("{} {}: {:.2}", group.label, series_label, bar_val.value),
);
let extra = if computed.interactive {
Some(format!(
"class=\"tt\" data-group=\"{}\" data-x=\"{}\" data-y=\"{:.4}\"",
series_label, group.label, bar_val.value
))
} else {
None
};
if tip.is_some() || extra.is_some() {
scene.add(Primitive::GroupStart {
transform: None,
title: tip,
extra_attrs: extra,
});
}
scene.add(Primitive::Rect {
x: x0,
y: y1.min(y0),
width: (x1 - x0).abs(),
height: (y0 - y1).abs(),
fill: Color::from(&bar_val.color),
stroke: None,
stroke_width: None,
opacity: None,
});
if bar.show_tooltips || computed.interactive {
scene.add(Primitive::GroupEnd);
}
y_accum += bar_val.value;
flat_i += 1;
}
} else {
let n = group.bars.len();
let single_width = total_width / n as f64;
for (j, bar_val) in group.bars.iter().enumerate() {
let x = group_x - total_width / 2.0 + single_width * (j as f64 + 0.5);
let x0 = computed.map_x(x - single_width / 2.0);
let x1 = computed.map_x(x + single_width / 2.0);
let y0 = computed.map_y(0.0);
let y1 = computed.map_y(bar_val.value);
let series_label = if n == 1 {
group.label.as_str()
} else {
bar.legend_label
.as_ref()
.and_then(|ll| ll.get(j))
.map(|s| s.as_str())
.unwrap_or(&group.label)
};
let tip = tooltip(
bar.show_tooltips || computed.interactive,
&bar.tooltip_labels,
flat_i,
|| format!("{} {}: {:.2}", group.label, series_label, bar_val.value),
);
let extra = if computed.interactive {
Some(format!(
"class=\"tt\" data-group=\"{}\" data-x=\"{}\" data-y=\"{:.4}\"",
series_label, group.label, bar_val.value
))
} else {
None
};
if tip.is_some() || extra.is_some() {
scene.add(Primitive::GroupStart {
transform: None,
title: tip,
extra_attrs: extra,
});
}
scene.add(Primitive::Rect {
x: x0,
y: y1.min(y0),
width: (x1 - x0).abs(),
height: (y0 - y1).abs(),
fill: Color::from(&bar_val.color),
stroke: None,
stroke_width: None,
opacity: None,
});
if bar.show_tooltips || computed.interactive {
scene.add(Primitive::GroupEnd);
}
flat_i += 1;
}
}
}
}
fn add_histogram(hist: &Histogram, scene: &mut Scene, computed: &ComputedLayout) {
if let Some((edges, counts)) = &hist.precomputed {
let max_count = counts.iter().cloned().fold(0.0_f64, f64::max).max(1.0);
let norm = if hist.normalize { 1.0 / max_count } else { 1.0 };
for (i, count) in counts.iter().enumerate() {
if i + 1 >= edges.len() {
break;
}
if *count == 0.0 {
continue;
}
let x0 = computed.map_x(edges[i]);
let x1 = computed.map_x(edges[i + 1]);
let y0 = computed.map_y(0.0);
let y1 = computed.map_y(count * norm);
let tip = tooltip(hist.show_tooltips, &hist.tooltip_labels, i, || {
format!("[{:.2}, {:.2}): {:.2}", edges[i], edges[i + 1], count)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Rect {
x: x0,
y: y1.min(y0),
width: (x1 - x0).abs(),
height: (y0 - y1).abs(),
fill: Color::from(&hist.color),
stroke: None,
stroke_width: None,
opacity: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
return;
}
let range: (f64, f64) = hist.range.unwrap_or_else(|| {
let min: f64 = hist.data.iter().cloned().fold(f64::INFINITY, f64::min);
let max: f64 = hist.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(min, max)
});
let bin_width: f64 = (range.1 - range.0) / hist.bins as f64;
let mut counts: Vec<usize> = vec![0; hist.bins];
for &value in &hist.data {
if value < range.0 || value > range.1 {
continue;
}
let bin: usize = ((value - range.0) / bin_width).floor() as usize;
let bin: usize = if bin == hist.bins { bin - 1 } else { bin };
counts[bin] += 1;
}
let max_count: f64 = *counts.iter().max().unwrap_or(&1) as f64;
let norm: f64 = if hist.normalize { 1.0 / max_count } else { 1.0 };
for (i, count) in counts.iter().enumerate() {
if *count == 0 {
continue;
}
let x = range.0 + i as f64 * bin_width;
let height = *count as f64 * norm;
let x0 = computed.map_x(x);
let x1 = computed.map_x(x + bin_width);
let y0 = computed.map_y(0.0);
let y1 = computed.map_y(height);
let rect_width = (x1 - x0).abs();
let rect_height = (y0 - y1).abs();
let tip = tooltip(hist.show_tooltips, &hist.tooltip_labels, i, || {
format!("[{:.2}, {:.2}): {}", x, x + bin_width, count)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Rect {
x: x0,
y: y1.min(y0),
width: rect_width,
height: rect_height,
fill: Color::from(&hist.color),
stroke: None,
stroke_width: None,
opacity: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
}
fn add_histogram2d(hist2d: &Histogram2D, scene: &mut Scene, computed: &ComputedLayout) {
let max_count = hist2d.bins.iter().flatten().copied().max().unwrap_or(1) as f64;
let log_scale = hist2d.log_count;
let log_max = (max_count + 1.0).log10();
let x_bin_width = (hist2d.x_range.1 - hist2d.x_range.0) / hist2d.bins_x as f64;
let y_bin_height = (hist2d.y_range.1 - hist2d.y_range.0) / hist2d.bins_y as f64;
let cmap = hist2d.color_map.clone();
for (row_idx, row) in hist2d.bins.iter().enumerate() {
for (col_idx, &count) in row.iter().enumerate() {
if count == 0 {
continue;
}
let x0 = hist2d.x_range.0 + col_idx as f64 * x_bin_width;
let y0 = hist2d.y_range.0 + row_idx as f64 * y_bin_height;
let x1 = x0 + x_bin_width;
let y1 = y0 + y_bin_height;
let norm = if log_scale {
((count as f64 + 1.0).log10() / log_max).clamp(0.0, 1.0)
} else {
(count as f64 / max_count).clamp(0.0, 1.0)
};
let color = cmap.map(norm);
scene.add(Primitive::Rect {
x: computed.map_x(x0),
y: computed.map_y(y1), width: computed.map_x(x1) - computed.map_x(x0),
height: computed.map_y(y0) - computed.map_y(y1),
fill: color.into(),
stroke: None,
stroke_width: None,
opacity: None,
});
}
}
if hist2d.show_correlation {
let corr =
pearson_corr(&hist2d.data).expect("hist2d correlation requires at least 2 data points");
scene.add(Primitive::Text {
x: computed.width - 120.0,
y: computed.margin_top + 20.0,
content: format!("r = {:.2}", corr),
size: computed.body_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
}
fn add_boxplot(boxplot: &BoxPlot, scene: &mut Scene, computed: &ComputedLayout) {
let theme = &computed.theme;
for (i, group) in boxplot.groups.iter().enumerate() {
if group.values.is_empty() {
continue;
}
let color = boxplot
.group_colors
.as_ref()
.and_then(|c| c.get(i).map(|s| s.as_str()))
.unwrap_or(&boxplot.color);
let mut sorted = group.values.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
let q1 = percentile(&sorted, 25.0); let q2 = percentile(&sorted, 50.0); let q3 = percentile(&sorted, 75.0); let iqr = q3 - q1;
let lower_whisker = sorted
.iter()
.cloned()
.filter(|v| *v >= q1 - 1.5 * iqr)
.fold(f64::INFINITY, f64::min);
let upper_whisker = sorted
.iter()
.cloned()
.filter(|v| *v <= q3 + 1.5 * iqr)
.fold(f64::NEG_INFINITY, f64::max);
let x = i as f64 + 1.0;
let w = boxplot.width / 2.0;
let x0 = computed.map_x(x - w);
let x1 = computed.map_x(x + w);
let yq1 = computed.map_y(q1);
let yq3 = computed.map_y(q3);
let ymed = computed.map_y(q2);
let ylow = computed.map_y(lower_whisker);
let yhigh = computed.map_y(upper_whisker);
let xmid = computed.map_x(x);
scene.add(Primitive::Rect {
x: x0,
y: yq3.min(yq1),
width: (x1 - x0).abs(),
height: (yq1 - yq3).abs(),
fill: Color::from(color),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Line {
x1: x0,
y1: ymed,
x2: x1,
y2: ymed,
stroke: Color::from(&theme.box_median),
stroke_width: 1.5,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: xmid,
y1: ylow,
x2: xmid,
y2: yq1,
stroke: Color::from(color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: xmid,
y1: yq3,
x2: xmid,
y2: yhigh,
stroke: Color::from(color),
stroke_width: 1.0,
stroke_dasharray: None,
});
for &y in &[ylow, yhigh] {
scene.add(Primitive::Line {
x1: computed.map_x(x - w / 2.0),
x2: computed.map_x(x + w / 2.0),
y1: y,
y2: y,
stroke: Color::from(color),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
}
if let Some(ref style) = boxplot.overlay {
for (i, group) in boxplot.groups.iter().enumerate() {
add_strip_points(
&group.values,
(i + 1) as f64,
style,
&boxplot.overlay_color,
None,
None, boxplot.overlay_size,
boxplot.overlay_seed.wrapping_add(i as u64),
None,
None,
false,
None,
&group.label,
0,
scene,
computed,
);
}
}
}
fn add_violin(violin: &ViolinPlot, scene: &mut Scene, computed: &ComputedLayout) {
let theme = &computed.theme;
for (i, group) in violin.groups.iter().enumerate() {
if group.values.is_empty() {
continue;
}
let color = violin
.group_colors
.as_ref()
.and_then(|c| c.get(i).map(|s| s.as_str()))
.unwrap_or(&violin.color);
let x_center = computed.map_x((i + 1) as f64);
let h = violin
.bandwidth
.unwrap_or_else(|| render_utils::silverman_bandwidth(&group.values));
let kde = render_utils::simple_kde(&group.values, h, violin.kde_samples);
if kde.is_empty() {
continue;
}
let max_density = kde
.iter()
.map(|(_, y)| *y)
.fold(f64::NEG_INFINITY, f64::max);
let scale = violin.width / max_density;
let mut path_data = String::with_capacity(kde.len() * 32);
{
let mut rb = ryu::Buffer::new();
for (j, (y, d)) in kde.iter().enumerate() {
let dy = computed.map_y(*y);
let dx = x_center - d * scale;
path_data.push(if j == 0 { 'M' } else { 'L' });
path_data.push(' ');
path_data.push_str(rb.format(round2(dx)));
path_data.push(' ');
path_data.push_str(rb.format(round2(dy)));
path_data.push(' ');
}
for (y, d) in kde.iter().rev() {
let dy = computed.map_y(*y);
let dx = x_center + d * scale;
path_data.push_str("L ");
path_data.push_str(rb.format(round2(dx)));
path_data.push(' ');
path_data.push_str(rb.format(round2(dy)));
path_data.push(' ');
}
}
path_data.push('Z');
scene.add(Primitive::Path(Box::new(PathData {
d: path_data,
fill: Some(Color::from(color)),
stroke: Color::from(&theme.violin_border),
stroke_width: 0.5,
opacity: None,
stroke_dasharray: None,
})));
}
if let Some(ref style) = violin.overlay {
for (i, group) in violin.groups.iter().enumerate() {
add_strip_points(
&group.values,
(i + 1) as f64,
style,
&violin.overlay_color,
None,
None, violin.overlay_size,
violin.overlay_seed.wrapping_add(i as u64),
None,
None,
false,
None,
&group.label,
0,
scene,
computed,
);
}
}
}
fn add_pie(pie: &PiePlot, scene: &mut Scene, computed: &ComputedLayout) {
let theme = &computed.theme;
let total: f64 = pie.slices.iter().map(|s| s.value).sum();
let has_outside = matches!(
pie.label_position,
PieLabelPosition::Outside | PieLabelPosition::Auto
);
let leader_gap = 30.0;
let pad = 5.0;
let radius = if has_outside {
computed.plot_height() / 2.0 - pad
} else {
computed.plot_width().min(computed.plot_height()) / 2.0 - 10.0
};
let cx = computed.margin_left + computed.plot_width() / 2.0;
let cy = computed.margin_top + computed.plot_height() / 2.0;
let inner_radius = pie.inner_radius;
let inside_label_radius = (radius + inner_radius) / 2.0;
let mut angle = 0.0;
struct OutsideLabel {
content: String,
right_side: bool,
edge_x: f64,
edge_y: f64,
elbow_x: f64,
elbow_y: f64,
text_x: f64,
text_y: f64,
}
let mut outside_labels: Vec<OutsideLabel> = Vec::new();
for (slice_i, slice) in pie.slices.iter().enumerate() {
let frac = slice.value / total;
let sweep = frac * std::f64::consts::TAU;
let end_angle = angle + sweep;
let x1 = cx + radius * angle.cos();
let y1 = cy + radius * angle.sin();
let x2 = cx + radius * end_angle.cos();
let y2 = cy + radius * end_angle.sin();
let large_arc = if sweep > std::f64::consts::PI { 1 } else { 0 };
let path_data = if inner_radius == 0.0 {
format!(
"M{cx},{cy} L{x1},{y1} A{r},{r} 0 {large_arc},1 {x2},{y2} Z",
r = radius
)
} else {
let ix1 = cx + inner_radius * end_angle.cos();
let iy1 = cy + inner_radius * end_angle.sin();
let ix2 = cx + inner_radius * angle.cos();
let iy2 = cy + inner_radius * angle.sin();
format!(
"M{x1},{y1} A{r},{r} 0 {large_arc},1 {x2},{y2} L{ix1},{iy1} A{ir},{ir} 0 {large_arc},0 {ix2},{iy2} Z",
r = radius,
ir = inner_radius
)
};
let tip = tooltip(pie.show_tooltips, &pie.tooltip_labels, slice_i, || {
format!(
"{}: {:.2} ({:.1}%)",
slice.label,
slice.value,
slice.value / total * 100.0
)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Path(Box::new(PathData {
d: path_data,
fill: Some(Color::from(&slice.color)),
stroke: Color::from(&slice.color),
stroke_width: 1.0,
opacity: None,
stroke_dasharray: None,
})));
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
let label_text = if pie.show_percent {
let pct = frac * 100.0;
if slice.label.is_empty() {
format!("{:.1}%", pct)
} else {
format!("{} ({:.1}%)", slice.label, pct)
}
} else {
slice.label.clone()
};
let place_inside = match pie.label_position {
PieLabelPosition::None => {
angle = end_angle;
continue;
}
PieLabelPosition::Inside => true,
PieLabelPosition::Outside => false,
PieLabelPosition::Auto => frac >= pie.min_label_fraction,
};
let mid_angle = angle + sweep / 2.0;
if place_inside {
let label_x = cx + inside_label_radius * mid_angle.cos();
let label_y = cy + inside_label_radius * mid_angle.sin();
scene.add(Primitive::Text {
x: label_x,
y: label_y,
content: label_text,
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
} else {
let right_side = mid_angle.cos() >= 0.0;
let edge_x = cx + (radius + 5.0) * mid_angle.cos();
let edge_y = cy + (radius + 5.0) * mid_angle.sin();
let elbow_x = cx + (radius + 20.0) * mid_angle.cos();
let elbow_y = cy + (radius + 20.0) * mid_angle.sin();
let text_x = if right_side {
cx + radius + leader_gap
} else {
cx - radius - leader_gap
};
let text_y = elbow_y;
outside_labels.push(OutsideLabel {
content: label_text,
right_side,
edge_x,
edge_y,
elbow_x,
elbow_y,
text_x,
text_y,
});
}
angle = end_angle;
}
let min_gap = computed.body_size as f64 + 2.0;
for side in [true, false] {
let mut indices: Vec<usize> = outside_labels
.iter()
.enumerate()
.filter(|(_, l)| l.right_side == side)
.map(|(i, _)| i)
.collect();
indices.sort_by(|a, b| {
outside_labels[*a]
.text_y
.total_cmp(&outside_labels[*b].text_y)
});
for j in 1..indices.len() {
let prev_y = outside_labels[indices[j - 1]].text_y;
if outside_labels[indices[j]].text_y - prev_y < min_gap {
outside_labels[indices[j]].text_y = prev_y + min_gap;
}
}
}
for label in &outside_labels {
scene.add(Primitive::Line {
x1: label.edge_x,
y1: label.edge_y,
x2: label.elbow_x,
y2: label.elbow_y,
stroke: Color::from(&theme.pie_leader),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: label.elbow_x,
y1: label.elbow_y,
x2: label.text_x,
y2: label.text_y,
stroke: Color::from(&theme.pie_leader),
stroke_width: 1.0,
stroke_dasharray: None,
});
let anchor = if label.right_side {
TextAnchor::Start
} else {
TextAnchor::End
};
scene.add(Primitive::Text {
x: label.text_x,
y: label.text_y,
content: label.content.clone(),
size: computed.body_size,
anchor,
rotate: None,
bold: false,
color: None,
});
}
}
fn add_heatmap(heatmap: &Heatmap, scene: &mut Scene, computed: &ComputedLayout) {
let rows = heatmap.data.len();
let cols = heatmap.data.first().map_or(0, |row| row.len());
if rows == 0 || cols == 0 {
return;
}
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &v in heatmap.data.iter().flatten() {
if v < min {
min = v;
}
if v > max {
max = v;
}
}
let norm = |v: f64| (v - min) / (max - min + f64::EPSILON);
let cmap = heatmap.color_map.clone();
let total = rows * cols;
let (x_lo, x_hi) = heatmap.x_range.unwrap_or((0.5, cols as f64 + 0.5));
let (y_lo, y_hi) = heatmap.y_range.unwrap_or((0.5, rows as f64 + 0.5));
let x_step = (x_hi - x_lo) / cols as f64;
let y_step = (y_hi - y_lo) / rows as f64;
struct CellData {
x: f64,
y: f64,
w: f64,
h: f64,
full_w: f64,
full_h: f64,
fill: Color,
}
let cell_data: Vec<CellData> = heatmap
.data
.iter()
.enumerate()
.flat_map(|(i, row)| {
let cmap = cmap.clone();
row.iter().enumerate().map(move |(j, &value)| {
let x0 = computed.map_x(x_lo + j as f64 * x_step);
let x1 = computed.map_x(x_lo + (j + 1) as f64 * x_step);
let y0 = computed.map_y(y_lo + (i + 1) as f64 * y_step);
let y1 = computed.map_y(y_lo + i as f64 * y_step);
let full_w = (x1 - x0).abs();
let full_h = (y1 - y0).abs();
let (dw, dh) = if heatmap.cell_size >= 1.0 {
(full_w + 0.5, full_h + 0.5)
} else {
(full_w * heatmap.cell_size, full_h * heatmap.cell_size)
};
CellData {
x: x0,
y: y0,
w: dw,
h: dh,
full_w,
full_h,
fill: Color::from(cmap.map(norm(value))),
}
})
})
.collect();
let use_tooltips = heatmap.show_tooltips || heatmap.tooltip_labels.is_some();
if use_tooltips {
for (idx, cd) in cell_data.iter().enumerate() {
let row_i = idx / cols;
let col_i = idx % cols;
let value = heatmap.data[row_i][col_i];
let row_label = heatmap
.row_labels
.as_ref()
.and_then(|v| v.get(row_i))
.map(|s| s.as_str())
.unwrap_or("");
let col_label = heatmap
.col_labels
.as_ref()
.and_then(|v| v.get(col_i))
.map(|s| s.as_str())
.unwrap_or("");
let tip = tooltip(heatmap.show_tooltips, &heatmap.tooltip_labels, idx, || {
format!("{}, {}: {:.2}", row_label, col_label, value)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Rect {
x: cd.x,
y: cd.y,
width: cd.w,
height: cd.h,
fill: cd.fill.clone(),
stroke: None,
stroke_width: None,
opacity: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
} else {
let mut xs = Vec::with_capacity(total);
let mut ys = Vec::with_capacity(total);
let mut ws = Vec::with_capacity(total);
let mut hs = Vec::with_capacity(total);
let mut fills = Vec::with_capacity(total);
for cd in &cell_data {
xs.push(cd.x);
ys.push(cd.y);
ws.push(cd.w);
hs.push(cd.h);
fills.push(cd.fill.clone());
}
scene.add(Primitive::RectBatch {
x: xs,
y: ys,
w: ws,
h: hs,
fills,
});
}
if heatmap.show_values {
for (idx, cd) in cell_data.iter().enumerate() {
let i = idx / cols;
let j = idx % cols;
let _ = (i, j);
scene.add(Primitive::Text {
x: cd.x + cd.full_w / 2.0,
y: cd.y + cd.full_h / 2.0,
content: format!("{:.2}", heatmap.data[idx / cols][idx % cols]),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
fn add_brickplot(brickplot: &BrickPlot, scene: &mut Scene, computed: &ComputedLayout) {
let rows: &Vec<String> = if let Some(ref exp) = brickplot.strigar_exp {
exp
} else {
&brickplot.sequences
};
let num_rows = rows.len();
if num_rows == 0 {
return;
}
let has_variable_width = brickplot.motif_lengths.is_some();
let row_offset = |i: usize| -> f64 {
let per_row = if let Some(ref offsets) = brickplot.x_offsets {
offsets
.get(i)
.copied()
.flatten()
.unwrap_or(brickplot.x_offset)
} else {
brickplot.x_offset
};
per_row + brickplot.x_origin
};
let str_width = |i: usize| -> f64 {
rows[i]
.chars()
.map(|ch| {
if let Some(ref ml) = brickplot.motif_lengths {
*ml.get(&ch).unwrap_or(&1) as f64
} else {
1.0
}
})
.sum()
};
let left_len = |i: usize| -> f64 {
brickplot
.left_flanks
.as_ref()
.and_then(|f| f.get(i))
.map(|s| s.chars().count() as f64)
.unwrap_or(0.0)
};
let right_len = |i: usize| -> f64 {
brickplot
.right_flanks
.as_ref()
.and_then(|f| f.get(i))
.map(|s| s.chars().count() as f64)
.unwrap_or(0.0)
};
let right_align_shift: Vec<f64> = if brickplot.anchor == BrickAnchor::Right {
let right_edges: Vec<f64> = (0..num_rows).map(|i| str_width(i) + right_len(i)).collect();
let max_right = right_edges
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
right_edges.iter().map(|&re| max_right - re).collect()
} else {
vec![0.0; num_rows]
};
let dna_color = |ch: char| -> &'static str {
match ch {
'A' | 'a' => "rgb(0,150,0)",
'C' | 'c' => "rgb(0,0,255)",
'G' | 'g' => "rgb(209,113,5)",
'T' | 't' => "rgb(255,0,0)",
_ => "rgb(180,180,180)",
}
};
let draw_brick =
|scene: &mut Scene, x_start: f64, width: f64, yr: usize, eff_offset: f64, fill: Color| {
let x0 = computed.map_x(x_start - eff_offset);
let x1 = computed.map_x(x_start + width - eff_offset);
let y0 = computed.map_y(yr as f64 + 1.0);
let y1 = computed.map_y(yr as f64);
scene.add(Primitive::Rect {
x: x0,
y: y0,
width: (x1 - x0).abs() * 0.95,
height: (y1 - y0).abs() * 0.95,
fill,
stroke: None,
stroke_width: None,
opacity: None,
});
};
for i in 0..num_rows {
let yr = num_rows - 1 - i;
let eff_offset = row_offset(i) - right_align_shift[i];
let ll = left_len(i);
let sw = str_width(i);
if let Some(ref flanks) = brickplot.left_flanks {
if let Some(flank) = flanks.get(i) {
for (k, ch) in flank.chars().enumerate() {
let x_start = -(ll) + k as f64;
draw_brick(
scene,
x_start,
1.0,
yr,
eff_offset,
Color::from(dna_color(ch)),
);
}
}
}
let row = &rows[i];
let template = brickplot
.template
.as_ref()
.expect("BrickPlot rendered without template");
let mut x_pos: f64 = 0.0;
for (j, value) in row.chars().enumerate() {
let width = if let Some(ref ml) = brickplot.motif_lengths {
*ml.get(&value).unwrap_or(&1) as f64
} else {
1.0
};
let x_start = if has_variable_width { x_pos } else { j as f64 };
let color = template
.get(&value)
.expect("BrickPlot value not found in template colormap");
draw_brick(
scene,
x_start,
width,
yr,
eff_offset,
Color::from(color.as_str()),
);
x_pos += width;
}
if let Some(ref flanks) = brickplot.right_flanks {
if let Some(flank) = flanks.get(i) {
for (k, ch) in flank.chars().enumerate() {
let x_start = sw + k as f64;
draw_brick(
scene,
x_start,
1.0,
yr,
eff_offset,
Color::from(dna_color(ch)),
);
}
}
}
}
if brickplot.show_values {
for i in 0..num_rows {
let yr = num_rows - 1 - i;
let eff_offset = row_offset(i) - right_align_shift[i];
let row = &rows[i];
let mut x_pos: f64 = 0.0;
for (j, value) in row.chars().enumerate() {
let width = if let Some(ref ml) = brickplot.motif_lengths {
*ml.get(&value).unwrap_or(&1) as f64
} else {
1.0
};
let x_start = if has_variable_width { x_pos } else { j as f64 };
let x0 = computed.map_x(x_start - eff_offset);
let x1 = computed.map_x(x_start + width - eff_offset);
let y0 = computed.map_y(yr as f64 + 1.0);
let y1 = computed.map_y(yr as f64);
scene.add(Primitive::Text {
x: x0 + ((x1 - x0).abs() / 2.0),
y: y0 + ((y1 - y0).abs() / 2.0),
content: format!("{}", value),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
x_pos += width;
}
}
}
}
fn add_brickplot_notations(brickplot: &BrickPlot, scene: &mut Scene, computed: &ComputedLayout) {
let notations = match brickplot.notations.as_ref() {
Some(n) => n,
None => return,
};
let motifs_map = match brickplot.motifs.as_ref() {
Some(m) => m,
None => return,
};
let rows: &Vec<String> = if let Some(ref exp) = brickplot.strigar_exp {
exp
} else {
&brickplot.sequences
};
let num_rows = rows.len();
if num_rows == 0 {
return;
}
let row_offset = |i: usize| -> f64 {
let per_row = if let Some(ref offsets) = brickplot.x_offsets {
offsets
.get(i)
.copied()
.flatten()
.unwrap_or(brickplot.x_offset)
} else {
brickplot.x_offset
};
per_row + brickplot.x_origin
};
let str_width = |i: usize| -> f64 {
rows[i]
.chars()
.map(|ch| {
if let Some(ref ml) = brickplot.motif_lengths {
*ml.get(&ch).unwrap_or(&1) as f64
} else {
1.0
}
})
.sum()
};
let right_len = |i: usize| -> f64 {
brickplot
.right_flanks
.as_ref()
.and_then(|f| f.get(i))
.map(|s| s.chars().count() as f64)
.unwrap_or(0.0)
};
let right_align_shift: Vec<f64> = if brickplot.anchor == BrickAnchor::Right {
let right_edges: Vec<f64> = (0..num_rows).map(|i| str_width(i) + right_len(i)).collect();
let max_right = right_edges
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
right_edges.iter().map(|&re| max_right - re).collect()
} else {
vec![0.0; num_rows]
};
const N_TIERS: usize = 4;
let font_px = computed.body_size as f64;
let label_size = (font_px * 0.85) as u32;
let line_h = font_px * 1.1;
for (i, notation_opt) in notations.iter().enumerate() {
if notation_opt.is_none() {
continue;
}
if i >= num_rows {
continue;
}
let yr = num_rows - 1 - i;
let eff_offset = row_offset(i) - right_align_shift[i];
let y_top_px = computed.map_y((yr + 1) as f64);
let row = &rows[i];
let letter_width = |ch: char| -> f64 {
if let Some(ref ml) = brickplot.motif_lengths {
*ml.get(&ch).unwrap_or(&1) as f64
} else {
1.0
}
};
struct Run {
letter: char,
count: usize,
x_start: f64,
x_end: f64,
}
let mut runs: Vec<Run> = Vec::new();
let mut cum_x: f64 = 0.0;
let mut run_letter: Option<char> = None;
let mut run_start_x: f64 = 0.0;
let mut run_count: usize = 0;
for ch in row.chars() {
let w = letter_width(ch);
if Some(ch) == run_letter {
run_count += 1;
} else {
if let Some(rl) = run_letter {
runs.push(Run {
letter: rl,
count: run_count,
x_start: run_start_x,
x_end: cum_x,
});
}
run_letter = Some(ch);
run_count = 1;
run_start_x = cum_x;
}
cum_x += w;
}
if let Some(rl) = run_letter {
runs.push(Run {
letter: rl,
count: run_count,
x_start: run_start_x,
x_end: cum_x,
});
}
let plot_px = computed.plot_width();
let labelable: Vec<String> = runs
.iter()
.filter(|r| r.letter != '@')
.filter_map(|r| {
motifs_map
.get(&r.letter)
.map(|k| format!("({}){}", k, r.count))
})
.collect();
let total_label_px: f64 = labelable
.iter()
.map(|l| l.len() as f64 * font_px * 0.56)
.sum();
if total_label_px > plot_px * 2.0 {
let cx = computed.margin_left + plot_px / 2.0;
scene.add(Primitive::Text {
x: cx,
y: y_top_px - 2.0 - 0.5 * line_h,
content: "complex structure: see TSV".to_string(),
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
continue;
}
let plot_left_px = computed.margin_left;
let plot_right_px = computed.width - computed.margin_right;
let mut last_right: [f64; N_TIERS] = [f64::NEG_INFINITY; N_TIERS];
for run in &runs {
if run.letter == '@' {
continue;
}
let kmer = match motifs_map.get(&run.letter) {
Some(k) => k.as_str(),
None => continue,
};
let label = format!("({}){}", kmer, run.count);
let center_px = computed.map_x((run.x_start + run.x_end) / 2.0 - eff_offset);
let text_half_w = label.len() as f64 * font_px * 0.28;
let clamped_center = center_px
.max(plot_left_px + text_half_w + 2.0)
.min(plot_right_px - text_half_w - 2.0);
let left_px = clamped_center - text_half_w;
let right_px = clamped_center + text_half_w;
let chosen = (0..N_TIERS).find(|&t| left_px > last_right[t]).unwrap_or(0);
last_right[chosen] = right_px;
let y_text = y_top_px - 2.0 - (chosen as f64 + 0.5) * line_h;
scene.add(Primitive::Text {
x: clamped_center,
y: y_text,
content: label,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
#[allow(clippy::too_many_arguments)]
fn add_strip_points(
values: &[f64],
x_center_data: f64,
style: &StripStyle,
color: &str,
point_colors: Option<&[String]>,
point_shapes: Option<&[MarkerShape]>,
point_size: f64,
seed: u64,
fill_opacity: Option<f64>,
stroke_width: Option<f64>,
show_tooltips: bool,
tooltip_labels: Option<&[String]>,
group_label: &str,
label_offset: usize,
scene: &mut Scene,
computed: &ComputedLayout,
) {
let resolve_color = |j: usize| -> &str {
point_colors
.and_then(|c| c.get(j).map(|s| s.as_str()))
.unwrap_or(color)
};
let resolve_shape = |j: usize| -> MarkerShape {
point_shapes
.and_then(|s| s.get(j).copied())
.unwrap_or(MarkerShape::Circle)
};
let draw_point = |j: usize, cx: f64, cy: f64, scene: &mut Scene| {
let fill_color = resolve_color(j);
let stroke_col = stroke_width.map(|_| Color::from(fill_color));
draw_marker(
scene,
resolve_shape(j),
cx,
cy,
point_size,
fill_color,
fill_opacity,
stroke_col,
stroke_width,
);
};
let tooltip_labels_opt: Option<Vec<String>> = tooltip_labels.map(|s| s.to_vec());
let strip_extra = |v: f64| -> Option<String> {
if computed.interactive {
Some(format!(
"class=\"tt\" data-group=\"{}\" data-y=\"{v}\"",
group_label
))
} else {
None
}
};
match style {
StripStyle::Center => {
let cx = computed.map_x(x_center_data);
for (j, &v) in values.iter().enumerate() {
let tip = tooltip(
show_tooltips || computed.interactive,
&tooltip_labels_opt,
label_offset + j,
|| format!("{}: {:.2}", group_label, v),
);
let extra = strip_extra(v);
if tip.is_some() || extra.is_some() {
scene.add(Primitive::GroupStart {
transform: None,
title: tip.clone(),
extra_attrs: extra,
});
}
draw_point(j, cx, computed.map_y(v), scene);
if tip.is_some() || computed.interactive {
scene.add(Primitive::GroupEnd);
}
}
}
StripStyle::Strip { jitter } => {
let mut rng_state = seed ^ 0x9e3779b97f4a7c15u64;
for (j, &v) in values.iter().enumerate() {
rng_state ^= rng_state << 13;
rng_state ^= rng_state >> 7;
rng_state ^= rng_state << 17;
let rand_val = (rng_state >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
let offset: f64 = (rand_val - 0.5) * jitter;
let cx = computed.map_x(x_center_data + offset);
let tip = tooltip(
show_tooltips || computed.interactive,
&tooltip_labels_opt,
label_offset + j,
|| format!("{}: {:.2}", group_label, v),
);
let extra = strip_extra(v);
if tip.is_some() || extra.is_some() {
scene.add(Primitive::GroupStart {
transform: None,
title: tip.clone(),
extra_attrs: extra,
});
}
draw_point(j, cx, computed.map_y(v), scene);
if tip.is_some() || computed.interactive {
scene.add(Primitive::GroupEnd);
}
}
}
StripStyle::Swarm => {
let y_screen: Vec<f64> = values.iter().map(|&v| computed.map_y(v)).collect();
let x_offsets = render_utils::beeswarm_positions(&y_screen, point_size);
let cx_center = computed.map_x(x_center_data);
for (j, &v) in values.iter().enumerate() {
let cx = cx_center + x_offsets[j];
let tip = tooltip(
show_tooltips || computed.interactive,
&tooltip_labels_opt,
label_offset + j,
|| format!("{}: {:.2}", group_label, v),
);
let extra = strip_extra(v);
if tip.is_some() || extra.is_some() {
scene.add(Primitive::GroupStart {
transform: None,
title: tip.clone(),
extra_attrs: extra,
});
}
draw_point(j, cx, computed.map_y(v), scene);
if tip.is_some() || computed.interactive {
scene.add(Primitive::GroupEnd);
}
}
}
}
}
fn add_strip(strip: &StripPlot, scene: &mut Scene, computed: &ComputedLayout) {
let mut label_offset: usize = 0;
for (i, group) in strip.groups.iter().enumerate() {
let color = strip
.group_colors
.as_ref()
.and_then(|c| c.get(i).map(|s| s.as_str()))
.unwrap_or(&strip.color);
add_strip_points(
&group.values,
(i + 1) as f64,
&strip.style,
color,
group.point_colors.as_deref(),
group.point_shapes.as_deref(),
strip.point_size,
strip.seed.wrapping_add(i as u64),
strip.marker_opacity,
strip.marker_stroke_width,
strip.show_tooltips,
strip.tooltip_labels.as_deref(),
&group.label,
label_offset,
scene,
computed,
);
label_offset += group.values.len();
}
}
use crate::plot::plot3d::{Box3DConfig, DataRanges3D};
use crate::render::projection::Projection3D;
fn draw_3d_box(
ranges: DataRanges3D,
cfg: &Box3DConfig,
scene: &mut Scene,
computed: &ComputedLayout,
) -> Projection3D {
let (x_min, x_max) = ranges.x;
let (y_min, y_max) = ranges.y;
let (z_min, z_max) = ranges.z;
let plot_w = computed.plot_width();
let plot_h = computed.plot_height();
let plot_size = plot_w.min(plot_h);
let plot_cx = computed.margin_left + plot_w / 2.0;
let plot_cy = computed.margin_top + plot_h / 2.0;
let (fc_x, fc_y) = cfg.view.front_bottom_corner();
let x_range = if fc_x > 0.0 {
(x_max, x_min)
} else {
(x_min, x_max)
};
let y_range = if fc_y < 0.0 {
(y_max, y_min)
} else {
(y_min, y_max)
};
let proj = Projection3D::new(
cfg.view,
x_range,
y_range,
(z_min, z_max),
plot_cx,
plot_cy,
plot_size,
);
let view_dir = proj.view_direction();
let grid_n = cfg.grid_lines;
#[derive(Clone, Copy)]
struct Edge {
a: [f64; 3],
b: [f64; 3],
}
let corners: [[f64; 3]; 8] = [
[-0.5, -0.5, -0.5],
[0.5, -0.5, -0.5],
[0.5, 0.5, -0.5],
[-0.5, 0.5, -0.5],
[-0.5, -0.5, 0.5],
[0.5, -0.5, 0.5],
[0.5, 0.5, 0.5],
[-0.5, 0.5, 0.5],
];
let edge_indices: [(usize, usize); 12] = [
(0, 1),
(1, 2),
(2, 3),
(3, 0),
(4, 5),
(5, 6),
(6, 7),
(7, 4),
(0, 4),
(1, 5),
(2, 6),
(3, 7),
];
let face_normals: [[f64; 3]; 6] = [
[0.0, 0.0, -1.0],
[0.0, 0.0, 1.0],
[-1.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, -1.0, 0.0],
[0.0, 1.0, 0.0],
];
let face_edges: [&[usize]; 6] = [
&[0, 1, 2, 3],
&[4, 5, 6, 7],
&[3, 7, 8, 11],
&[1, 5, 9, 10],
&[0, 4, 8, 9],
&[2, 6, 10, 11],
];
let face_front: [bool; 6] = std::array::from_fn(|i| {
let n = &face_normals[i];
n[0] * view_dir[0] + n[1] * view_dir[1] + n[2] * view_dir[2] > 0.0
});
let edges: [Edge; 12] = std::array::from_fn(|i| {
let (a, b) = edge_indices[i];
Edge {
a: corners[a],
b: corners[b],
}
});
let mut edge_has_back = [false; 12];
let mut edge_has_front = [false; 12];
for (fi, fe) in face_edges.iter().enumerate() {
for &ei in *fe {
if face_front[fi] {
edge_has_front[ei] = true;
} else {
edge_has_back[ei] = true;
}
}
}
let theme = &computed.theme;
let silhouette_color = Color::from(theme.axis_color.as_str());
let back_edge_color = Color::from(theme.grid_color.as_str());
if cfg.show_box {
for (i, edge) in edges.iter().enumerate() {
if !edge_has_back[i] {
continue;
}
let (x1, y1, _) = proj.project_normalized(edge.a[0], edge.a[1], edge.a[2]);
let (x2, y2, _) = proj.project_normalized(edge.b[0], edge.b[1], edge.b[2]);
let is_silhouette = edge_has_front[i];
scene.add(Primitive::Line {
x1: round2(x1),
y1: round2(y1),
x2: round2(x2),
y2: round2(y2),
stroke: if is_silhouette {
silhouette_color.clone()
} else {
back_edge_color.clone()
},
stroke_width: if is_silhouette { 1.0 } else { 0.5 },
stroke_dasharray: None,
});
}
}
let face_corners: [[usize; 4]; 6] = [
[0, 1, 2, 3],
[4, 5, 6, 7],
[0, 3, 7, 4],
[1, 2, 6, 5],
[0, 1, 5, 4],
[3, 2, 6, 7],
];
let pane_fill = Color::from(theme.grid_color.as_str());
for (fi, fc) in face_corners.iter().enumerate() {
if face_front[fi] {
continue;
}
let pts: Vec<(f64, f64)> = fc
.iter()
.map(|&ci| {
let (sx, sy, _) =
proj.project_normalized(corners[ci][0], corners[ci][1], corners[ci][2]);
(sx, sy)
})
.collect();
let mut d = build_path(&pts);
d.push('Z');
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(pane_fill.clone()),
stroke: Color::None,
stroke_width: 0.0,
opacity: Some(0.15),
stroke_dasharray: None,
})));
}
if cfg.show_grid && grid_n > 0 {
let grid_color = Color::from(theme.grid_color.as_str());
type EndpointFn = fn(f64) -> ([f64; 3], [f64; 3]);
let grid_faces: [(usize, EndpointFn, EndpointFn); 5] = [
(
0,
|t| ([-0.5, t, -0.5], [0.5, t, -0.5]),
|t| ([t, -0.5, -0.5], [t, 0.5, -0.5]),
),
(
2,
|t| ([-0.5, t, -0.5], [-0.5, t, 0.5]),
|t| ([-0.5, -0.5, t], [-0.5, 0.5, t]),
),
(
3,
|t| ([0.5, t, -0.5], [0.5, t, 0.5]),
|t| ([0.5, -0.5, t], [0.5, 0.5, t]),
),
(
4,
|t| ([t, -0.5, -0.5], [t, -0.5, 0.5]),
|t| ([-0.5, -0.5, t], [0.5, -0.5, t]),
),
(
5,
|t| ([t, 0.5, -0.5], [t, 0.5, 0.5]),
|t| ([-0.5, 0.5, t], [0.5, 0.5, t]),
),
];
for i in 0..=grid_n {
let t = i as f64 / grid_n as f64 - 0.5;
for &(fi, line_a, line_b) in &grid_faces {
if face_front[fi] {
continue;
}
for line_fn in [line_a, line_b] {
let (a, b) = line_fn(t);
let (x1, y1, _) = proj.project_normalized(a[0], a[1], a[2]);
let (x2, y2, _) = proj.project_normalized(b[0], b[1], b[2]);
scene.add(Primitive::Line {
x1: round2(x1),
y1: round2(y1),
x2: round2(x2),
y2: round2(y2),
stroke: grid_color.clone(),
stroke_width: 0.5,
stroke_dasharray: None,
});
}
}
}
}
let tick_color = Color::from(theme.tick_color.as_str());
let body_size = computed.body_size;
let tick_size = body_size.saturating_sub(2).max(8);
let screen_dir = |ax: f64, ay: f64, az: f64, bx: f64, by: f64, bz: f64| -> (f64, f64) {
let (sx1, sy1, _) = proj.project_normalized(ax, ay, az);
let (sx2, sy2, _) = proj.project_normalized(bx, by, bz);
let dx = sx2 - sx1;
let dy = sy2 - sy1;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
(dx / len, dy / len)
};
let perp_vec = |ax: f64, ay: f64, az: f64, px: f64, py: f64, pz: f64| -> (f64, f64) {
let (sx, sy, _) = proj.project_normalized(ax, ay, az);
let (ox, oy, _) = proj.project_normalized(ax + px, ay + py, az + pz);
let rdx = ox - sx;
let rdy = oy - sy;
let len = (rdx * rdx + rdy * rdy).sqrt().max(1e-9);
(rdx / len, rdy / len)
};
let anchor_for = |dx: f64| -> TextAnchor {
if dx < -0.3 {
TextAnchor::End
} else if dx > 0.3 {
TextAnchor::Start
} else {
TextAnchor::Middle
}
};
let angle_deg = |dx: f64, dy: f64| -> f64 {
let a = dy.atan2(dx).to_degrees();
if a > 90.0 {
a - 180.0
} else if a < -90.0 {
a + 180.0
} else {
a
}
};
let tick_len = 6.0_f64;
let label_gap = 10.0_f64;
let axis_label_gap = 42.0_f64;
let x_ticks = render_utils::generate_ticks(x_min, x_max, grid_n.max(3));
{
let perp_sign = if fc_y < 0.0 { -0.1 } else { 0.1 };
let (ndx, ndy) = perp_vec(0.0, fc_y, -0.5, 0.0, perp_sign, 0.0);
let (edx, edy) = screen_dir(-0.5, fc_y, -0.5, 0.5, fc_y, -0.5);
for &tick_val in &x_ticks {
let t = (tick_val - x_range.0) / (x_range.1 - x_range.0) - 0.5;
if t.abs() > 0.501 {
continue;
}
let (sx, sy, _) = proj.project_normalized(t, fc_y, -0.5);
scene.add(Primitive::Line {
x1: round2(sx),
y1: round2(sy),
x2: round2(sx + ndx * tick_len),
y2: round2(sy + ndy * tick_len),
stroke: tick_color.clone(),
stroke_width: 0.8,
stroke_dasharray: None,
});
let lx = sx + ndx * (tick_len + label_gap);
let ly = sy + ndy * (tick_len + label_gap);
scene.add(Primitive::Text {
x: round2(lx),
y: round2(ly + 3.0),
content: TickFormat::Auto.format(tick_val),
size: tick_size,
anchor: TextAnchor::Middle,
rotate: Some(angle_deg(edx, edy)),
bold: false,
color: None,
});
}
if let Some(ref label) = cfg.x_label {
let (mx, my, _) = proj.project_normalized(0.0, fc_y, -0.5);
scene.add(Primitive::Text {
x: round2(mx + ndx * axis_label_gap),
y: round2(my + ndy * axis_label_gap + 4.0),
content: label.to_string(),
size: body_size,
anchor: TextAnchor::Middle,
rotate: Some(angle_deg(edx, edy)),
bold: true,
color: None,
});
}
}
let y_ticks = render_utils::generate_ticks(y_min, y_max, grid_n.max(3));
{
let perp_sign = if fc_x < 0.0 { -0.1 } else { 0.1 };
let (ndx, ndy) = perp_vec(fc_x, 0.0, -0.5, perp_sign, 0.0, 0.0);
let (edx, edy) = screen_dir(fc_x, -0.5, -0.5, fc_x, 0.5, -0.5);
for &tick_val in &y_ticks {
let t = (tick_val - y_range.0) / (y_range.1 - y_range.0) - 0.5;
if t.abs() > 0.501 {
continue;
}
let (sx, sy, _) = proj.project_normalized(fc_x, t, -0.5);
scene.add(Primitive::Line {
x1: round2(sx),
y1: round2(sy),
x2: round2(sx + ndx * tick_len),
y2: round2(sy + ndy * tick_len),
stroke: tick_color.clone(),
stroke_width: 0.8,
stroke_dasharray: None,
});
let lx = sx + ndx * (tick_len + label_gap);
let ly = sy + ndy * (tick_len + label_gap);
scene.add(Primitive::Text {
x: round2(lx),
y: round2(ly + 3.0),
content: TickFormat::Auto.format(tick_val),
size: tick_size,
anchor: TextAnchor::Middle,
rotate: Some(angle_deg(edx, edy)),
bold: false,
color: None,
});
}
if let Some(ref label) = cfg.y_label {
let (mx, my, _) = proj.project_normalized(fc_x, 0.0, -0.5);
scene.add(Primitive::Text {
x: round2(mx + ndx * axis_label_gap),
y: round2(my + ndy * axis_label_gap + 4.0),
content: label.to_string(),
size: body_size,
anchor: TextAnchor::Middle,
rotate: Some(angle_deg(edx, edy)),
bold: true,
color: None,
});
}
}
let z_ticks = render_utils::generate_ticks(z_min, z_max, grid_n.max(3));
{
let vert_edges: [(usize, usize, usize); 4] = [(0, 4, 8), (1, 5, 9), (2, 6, 10), (3, 7, 11)];
let z_right = cfg
.z_axis_right
.unwrap_or_else(|| cfg.view.auto_z_axis_right());
let mut best_edge = (0usize, 4usize);
let mut best_sx = if z_right {
f64::NEG_INFINITY
} else {
f64::INFINITY
};
for &(a, b, ei) in &vert_edges {
if !edge_has_back[ei] {
continue;
}
let mid_x = (corners[a][0] + corners[b][0]) / 2.0;
let mid_y = (corners[a][1] + corners[b][1]) / 2.0;
let mid_z = (corners[a][2] + corners[b][2]) / 2.0;
let (sx, _, _) = proj.project_normalized(mid_x, mid_y, mid_z);
let better = if z_right { sx > best_sx } else { sx < best_sx };
if better {
best_sx = sx;
best_edge = (a, b);
}
}
let edge_x = corners[best_edge.0][0];
let edge_y = corners[best_edge.0][1];
let perp_x = if edge_x < 0.0 { -0.1 } else { 0.1 };
let perp_y = if edge_y < 0.0 { -0.1 } else { 0.1 };
let (ndx, ndy) = perp_vec(edge_x, edge_y, 0.0, perp_x, perp_y, 0.0);
let (zdx, zdy) = screen_dir(edge_x, edge_y, -0.5, edge_x, edge_y, 0.5);
for &tick_val in &z_ticks {
let t = (tick_val - z_min) / (z_max - z_min) - 0.5;
if t.abs() > 0.501 {
continue;
}
let (sx, sy, _) = proj.project_normalized(edge_x, edge_y, t);
scene.add(Primitive::Line {
x1: round2(sx),
y1: round2(sy),
x2: round2(sx + ndx * tick_len),
y2: round2(sy + ndy * tick_len),
stroke: tick_color.clone(),
stroke_width: 0.8,
stroke_dasharray: None,
});
scene.add(Primitive::Text {
x: round2(sx + ndx * (tick_len + label_gap)),
y: round2(sy + ndy * (tick_len + label_gap) + 3.0),
content: TickFormat::Auto.format(tick_val),
size: tick_size,
anchor: anchor_for(ndx),
rotate: Some(angle_deg(zdx, zdy)),
bold: false,
color: None,
});
}
if let Some(ref label) = cfg.z_label {
let (mx, my, _) = proj.project_normalized(edge_x, edge_y, 0.0);
scene.add(Primitive::Text {
x: round2(mx + ndx * (axis_label_gap + 6.0)),
y: round2(my + ndy * (axis_label_gap + 6.0) + 4.0),
content: label.to_string(),
size: body_size,
anchor: TextAnchor::Middle,
rotate: Some(angle_deg(zdx, zdy)),
bold: true,
color: None,
});
}
}
proj
}
fn add_scatter3d(s: &Scatter3DPlot, scene: &mut Scene, computed: &ComputedLayout) {
let ranges = match s.data_ranges() {
Some(r) => r,
None => return,
};
let (z_min, z_max) = ranges.z;
let proj = draw_3d_box(ranges, &s.box3d, scene, computed);
let z_span = (z_max - z_min).max(f64::EPSILON);
let has_z_cmap = s.z_colormap.is_some();
struct ProjectedPoint {
sx: f64,
sy: f64,
depth: f64,
idx: usize,
}
let mut projected: Vec<ProjectedPoint> = s
.data
.iter()
.enumerate()
.map(|(i, p)| {
let (sx, sy, depth) = proj.project(p.x, p.y, p.z);
ProjectedPoint {
sx,
sy,
depth,
idx: i,
}
})
.collect();
projected.sort_by(|a, b| {
b.depth
.partial_cmp(&a.depth)
.unwrap_or(std::cmp::Ordering::Equal)
});
let (depth_min, depth_max) = if s.depth_shade {
let dmin = projected
.iter()
.map(|p| p.depth)
.fold(f64::INFINITY, f64::min);
let dmax = projected
.iter()
.map(|p| p.depth)
.fold(f64::NEG_INFINITY, f64::max);
(dmin, dmax)
} else {
(0.0, 1.0)
};
let depth_span = (depth_max - depth_min).max(1e-12);
let stroke_color = s.marker_stroke_width.map(|_| Color::from("#333333"));
let base_opacity = s.marker_opacity.unwrap_or(1.0);
for pp in &projected {
let i = pp.idx;
let pt = &s.data[i];
if !pt.x.is_finite() || !pt.y.is_finite() || !pt.z.is_finite() {
continue;
}
let point_size = s
.sizes
.as_ref()
.and_then(|v| v.get(i).copied())
.unwrap_or(s.size);
let owned_color: String;
let rgb_buf: [u8; 7]; let fill_ref: &str = if has_z_cmap {
let norm = (s.data[i].z - z_min) / z_span;
let cmap = s.z_colormap.as_ref().unwrap();
if let Some((r, g, b)) = cmap.map_rgb(norm) {
const HEX: &[u8; 16] = b"0123456789abcdef";
rgb_buf = [
b'#',
HEX[(r >> 4) as usize],
HEX[(r & 0xf) as usize],
HEX[(g >> 4) as usize],
HEX[(g & 0xf) as usize],
HEX[(b >> 4) as usize],
HEX[(b & 0xf) as usize],
];
std::str::from_utf8(&rgb_buf).unwrap()
} else {
owned_color = cmap.map(norm);
&owned_color
}
} else if let Some(ref colors) = s.colors {
colors.get(i).map(|c| c.as_str()).unwrap_or(&s.color)
} else {
&s.color
};
let opacity = if s.depth_shade {
let t = (pp.depth - depth_min) / depth_span;
Some(base_opacity * (1.0 - t * 0.7))
} else {
s.marker_opacity
};
draw_marker(
scene,
s.marker,
round2(pp.sx),
round2(pp.sy),
point_size,
fill_ref,
opacity,
stroke_color.clone(),
s.marker_stroke_width,
);
}
}
fn add_surface3d(s: &Surface3DPlot, scene: &mut Scene, computed: &ComputedLayout) {
let ranges = match s.data_ranges() {
Some(r) => r,
None => return,
};
let (z_min, z_max) = ranges.z;
let proj = draw_3d_box(ranges, &s.box3d, scene, computed);
let nrows = s.nrows();
let ncols = s.ncols();
let z_span = (z_max - z_min).max(f64::EPSILON);
let has_cmap = s.z_colormap.is_some();
struct Face {
pts: [(f64, f64); 4], depth: f64,
avg_z: f64,
}
let mut faces: Vec<Face> = Vec::with_capacity((nrows - 1) * (ncols - 1));
for i in 0..nrows - 1 {
for j in 0..ncols - 1 {
let corners_data = [
(s.x_at(j), s.y_at(i), s.z_data[i][j]),
(s.x_at(j + 1), s.y_at(i), s.z_data[i][j + 1]),
(s.x_at(j + 1), s.y_at(i + 1), s.z_data[i + 1][j + 1]),
(s.x_at(j), s.y_at(i + 1), s.z_data[i + 1][j]),
];
if corners_data
.iter()
.any(|c| !c.0.is_finite() || !c.1.is_finite() || !c.2.is_finite())
{
continue;
}
let mut total_depth = 0.0;
let mut total_z = 0.0;
let mut pts = [(0.0, 0.0); 4];
for (k, &(x, y, z)) in corners_data.iter().enumerate() {
let (sx, sy, d) = proj.project(x, y, z);
pts[k] = (round2(sx), round2(sy));
total_depth += d;
total_z += z;
}
faces.push(Face {
pts,
depth: total_depth / 4.0,
avg_z: total_z / 4.0,
});
}
}
faces.sort_by(|a, b| {
b.depth
.partial_cmp(&a.depth)
.unwrap_or(std::cmp::Ordering::Equal)
});
let wire_stroke = if s.show_wireframe {
Color::from(s.wireframe_color.as_str())
} else {
Color::None
};
let wire_width = if s.show_wireframe {
s.wireframe_width
} else {
0.0
};
let opacity = if s.alpha < 1.0 { Some(s.alpha) } else { None };
let base_fill = if !has_cmap {
Some(Color::from(s.color.as_str()))
} else {
None
};
for face in &faces {
let fill = if has_cmap {
let norm = (face.avg_z - z_min) / z_span;
let cmap = s.z_colormap.as_ref().unwrap();
if let Some((r, g, b)) = cmap.map_rgb(norm) {
Color::Rgb(r, g, b)
} else {
Color::from(cmap.map(norm).as_str())
}
} else {
base_fill.clone().unwrap()
};
let mut d = build_path(&face.pts);
d.push('Z');
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(fill),
stroke: wire_stroke.clone(),
stroke_width: wire_width,
opacity,
stroke_dasharray: None,
})));
}
}
fn add_forest(forest: &ForestPlot, scene: &mut Scene, computed: &ComputedLayout) {
let n = forest.rows.len();
if n == 0 {
return;
}
let max_weight = forest
.rows
.iter()
.filter_map(|r| r.weight)
.fold(0.0_f64, f64::max);
if forest.show_null_line {
if let Some(nv) = forest.null_value {
let x_px = computed.map_x(nv);
scene.add(Primitive::Line {
x1: x_px,
y1: computed.map_y(0.5),
x2: x_px,
y2: computed.map_y(n as f64 + 0.5),
stroke: Color::from("#999999"),
stroke_width: 1.0,
stroke_dasharray: Some("4,3".into()),
});
}
}
for (i, row) in forest.rows.iter().enumerate() {
let y_data = (n - i) as f64;
let y_px = computed.map_y(y_data);
let color_str = row.color.as_deref().unwrap_or(&forest.color);
let color = Color::from(color_str);
let x_lower = computed.map_x(row.ci_lower);
let x_upper = computed.map_x(row.ci_upper);
let est_px = computed.map_x(row.estimate);
let marker_half_w = if let Some(w) = row.weight {
if max_weight > 0.0 {
let scaled = forest.marker_size * (w / max_weight).sqrt();
scaled.max(forest.marker_size * 0.15).max(1.5)
} else {
forest.marker_size
}
} else {
forest.marker_size
};
scene.add(Primitive::Line {
x1: x_lower,
y1: y_px,
x2: x_upper,
y2: y_px,
stroke: color.clone(),
stroke_width: forest.whisker_width,
stroke_dasharray: None,
});
let cap = forest.cap_size;
if cap > 0.0 {
scene.add(Primitive::Line {
x1: x_lower,
y1: y_px - cap,
x2: x_lower,
y2: y_px + cap,
stroke: color.clone(),
stroke_width: forest.whisker_width,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: x_upper,
y1: y_px - cap,
x2: x_upper,
y2: y_px + cap,
stroke: color.clone(),
stroke_width: forest.whisker_width,
stroke_dasharray: None,
});
}
let mh = marker_half_w * 2.0;
scene.add(Primitive::Rect {
x: est_px - marker_half_w,
y: y_px - marker_half_w,
width: mh,
height: mh,
fill: color,
stroke: None,
stroke_width: None,
opacity: None,
});
}
}
pub fn render_forest(forest: &ForestPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_forest(forest, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_lollipop(
lp: &crate::plot::lollipop::LollipopPlot,
scene: &mut Scene,
computed: &ComputedLayout,
) {
if lp.points.is_empty() {
return;
}
let mut x_min = f64::INFINITY;
let mut x_max = f64::NEG_INFINITY;
for p in &lp.points {
x_min = x_min.min(p.x);
x_max = x_max.max(p.x);
}
for d in &lp.domains {
x_min = x_min.min(d.x_start);
x_max = x_max.max(d.x_end);
}
for domain in &lp.domains {
let x_left = computed
.map_x(domain.x_start)
.min(computed.map_x(domain.x_end));
let x_right = computed
.map_x(domain.x_start)
.max(computed.map_x(domain.x_end));
let y_top = computed
.map_y(lp.baseline)
.min(computed.map_y(lp.baseline - lp.domain_height));
let y_bot = computed
.map_y(lp.baseline)
.max(computed.map_y(lp.baseline - lp.domain_height));
let width = x_right - x_left;
let height = y_bot - y_top;
scene.add(Primitive::Rect {
x: x_left,
y: y_top,
width,
height,
fill: Color::from(domain.color.as_str()),
stroke: None,
stroke_width: None,
opacity: Some(domain.opacity),
});
if let Some(ref label) = domain.label {
scene.add(Primitive::Text {
x: x_left + width / 2.0,
y: y_top + height / 2.0 + computed.body_size as f64 * 0.35,
content: label.clone(),
size: (computed.body_size as f64 * 0.75) as u32,
anchor: TextAnchor::Middle,
bold: false,
rotate: None,
color: None,
});
}
}
if lp.show_baseline && x_min.is_finite() {
let baseline_px = computed.map_y(lp.baseline);
scene.add(Primitive::Line {
x1: computed.map_x(x_min),
y1: baseline_px,
x2: computed.map_x(x_max),
y2: baseline_px,
stroke: Color::from(lp.baseline_color.as_str()),
stroke_width: lp.baseline_width,
stroke_dasharray: lp.baseline_dash.clone(),
});
}
for point in &lp.points {
let x_px = computed.map_x(point.x);
let y_px = computed.map_y(point.y);
let base_px = computed.map_y(lp.baseline);
let color_str = point.color.as_deref().unwrap_or(&lp.color);
let color = Color::from(color_str);
scene.add(Primitive::Line {
x1: x_px,
y1: base_px,
x2: x_px,
y2: y_px,
stroke: color.clone(),
stroke_width: lp.stem_width,
stroke_dasharray: None,
});
let stroke_color = lp
.dot_stroke
.as_deref()
.map(Color::from)
.unwrap_or_else(|| color.clone());
scene.add(Primitive::Circle {
cx: x_px,
cy: y_px,
r: lp.dot_radius,
fill: color,
fill_opacity: None,
stroke: Some(stroke_color),
stroke_width: Some(lp.dot_stroke_width),
});
if let Some(ref label) = point.label {
let label_offset = lp.dot_radius + 4.0;
let label_y = if point.y >= lp.baseline {
y_px - label_offset
} else {
y_px + label_offset + computed.body_size as f64
};
scene.add(Primitive::Text {
x: x_px,
y: label_y,
content: label.clone(),
size: (computed.body_size as f64 * 0.80) as u32,
anchor: TextAnchor::Middle,
bold: false,
rotate: None,
color: None,
});
}
}
}
pub fn render_lollipop(lp: &crate::plot::lollipop::LollipopPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_lollipop(lp, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_survival(
sp: &crate::plot::survival::SurvivalPlot,
scene: &mut Scene,
computed: &ComputedLayout,
) {
use crate::plot::survival::{censoring_levels, km_curve};
use crate::render::palette::Palette;
if sp.groups.is_empty() {
return;
}
let cat10 = Palette::category10();
let t_max = sp
.groups
.iter()
.flat_map(|g| g.times.iter().copied())
.fold(0.0_f64, f64::max);
if t_max <= 0.0 {
return;
}
let x_end_px = computed.map_x(t_max);
for (i, group) in sp.groups.iter().enumerate() {
if group.times.is_empty() {
continue;
}
let color_str: &str = group
.color
.as_deref()
.or_else(|| {
sp.group_colors
.as_ref()
.and_then(|c| c.get(i).map(|s| s.as_str()))
})
.unwrap_or_else(|| {
if sp.groups.len() > 1 {
&cat10[i]
} else {
&sp.color
}
});
let color = Color::from(color_str);
let km = km_curve(&group.times, &group.events);
if sp.show_ci && km.len() > 1 {
let mut upper: Vec<(f64, f64)> = vec![(computed.map_x(0.0), computed.map_y(1.0))];
for pt in km.iter().skip(1) {
let prev_y = upper.last().unwrap().1;
upper.push((computed.map_x(pt.t), prev_y));
upper.push((computed.map_x(pt.t), computed.map_y(pt.hi)));
}
upper.push((x_end_px, upper.last().unwrap().1));
let mut lower: Vec<(f64, f64)> = vec![(computed.map_x(0.0), computed.map_y(1.0))];
for pt in km.iter().skip(1) {
let prev_y = lower.last().unwrap().1;
lower.push((computed.map_x(pt.t), prev_y));
lower.push((computed.map_x(pt.t), computed.map_y(pt.lo)));
}
lower.push((x_end_px, lower.last().unwrap().1));
let mut d = format!("M {},{}", round2(upper[0].0), round2(upper[0].1));
for &(x, y) in upper.iter().skip(1) {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
for &(x, y) in lower.iter().rev() {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
d.push_str(" Z");
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.clone()),
stroke: color.clone(),
stroke_width: 0.0,
opacity: Some(sp.ci_alpha),
stroke_dasharray: None,
})));
}
let mut d = format!(
"M {},{}",
round2(computed.map_x(0.0)),
round2(computed.map_y(1.0))
);
let mut prev_s = 1.0_f64;
for pt in km.iter().skip(1) {
d.push_str(&format!(
" H {} V {}",
round2(computed.map_x(pt.t)),
round2(computed.map_y(prev_s))
));
d.push_str(&format!(" V {}", round2(computed.map_y(pt.s))));
prev_s = pt.s;
}
d.push_str(&format!(" H {}", round2(x_end_px)));
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: color.clone(),
stroke_width: sp.line_width,
opacity: None,
stroke_dasharray: None,
})));
if sp.show_censoring {
let ticks = censoring_levels(&group.times, &group.events, &km);
let half = sp.censoring_size;
for (t, s) in ticks {
let cx = computed.map_x(t);
let cy = computed.map_y(s);
scene.add(Primitive::Line {
x1: cx,
y1: cy - half,
x2: cx,
y2: cy + half,
stroke: color.clone(),
stroke_width: sp.line_width,
stroke_dasharray: None,
});
}
}
}
if let Some(ref txt) = sp.pvalue_text {
let x = computed.margin_left + computed.plot_width() - 8.0;
let y = computed.margin_top + computed.body_size as f64 * 1.5;
scene.add(Primitive::Text {
x,
y,
content: txt.clone(),
size: computed.body_size,
anchor: TextAnchor::End,
bold: false,
rotate: None,
color: None,
});
}
}
pub fn render_survival(sp: &crate::plot::survival::SurvivalPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_survival(sp, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_roc(roc: &RocPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::roc::{compute_group, RocPoint};
use crate::render::palette::Palette;
if roc.show_diagonal {
scene.add(Primitive::Line {
x1: computed.map_x(0.0),
y1: computed.map_y(0.0),
x2: computed.map_x(1.0),
y2: computed.map_y(1.0),
stroke: Color::from(&roc.diagonal_color),
stroke_width: computed.axis_stroke_width,
stroke_dasharray: Some(roc.diagonal_dasharray.clone()),
});
}
let cat10 = Palette::category10();
let n_groups = roc.groups.len();
for (i, group) in roc.groups.iter().enumerate() {
let color_str: &str = group.color.as_deref().unwrap_or_else(|| {
if n_groups == 1 {
&roc.color
} else {
&cat10[i % cat10.len()]
}
});
let color = Color::from(color_str);
let rc = compute_group(group);
if rc.points.is_empty() {
continue;
}
if group.show_ci && rc.ci_lo.is_finite() && rc.ci_hi.is_finite() {
let delta_up = rc.ci_hi - rc.auc;
let delta_dn = rc.auc - rc.ci_lo;
let map_upper = |pt: &RocPoint| -> (f64, f64) {
(
computed.map_x(pt.fpr),
computed.map_y((pt.tpr + delta_up).min(1.0)),
)
};
let map_lower = |pt: &RocPoint| -> (f64, f64) {
(
computed.map_x(pt.fpr),
computed.map_y((pt.tpr - delta_dn).max(0.0)),
)
};
let upper: Vec<(f64, f64)> = rc.points.iter().map(map_upper).collect();
let lower: Vec<(f64, f64)> = rc.points.iter().map(map_lower).collect();
let mut d = format!("M {},{}", round2(upper[0].0), round2(upper[0].1));
for &(x, y) in upper.iter().skip(1) {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
for &(x, y) in lower.iter().rev() {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
d.push_str(" Z");
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.clone()),
stroke: color.clone(),
stroke_width: 0.0,
opacity: Some(group.ci_alpha),
stroke_dasharray: None,
})));
}
let pts: Vec<(f64, f64)> = rc
.points
.iter()
.map(|pt| (computed.map_x(pt.fpr), computed.map_y(pt.tpr)))
.collect();
if !pts.is_empty() {
let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
for &(x, y) in pts.iter().skip(1) {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: color.clone(),
stroke_width: group.line_width,
opacity: None,
stroke_dasharray: group.dasharray.clone(),
})));
}
if let Some(opt_idx) = rc.optimal_idx {
if let Some(opt_pt) = rc.points.get(opt_idx) {
let cx = computed.map_x(opt_pt.fpr);
let cy = computed.map_y(opt_pt.tpr);
scene.add(Primitive::Circle {
cx,
cy,
r: 5.0 * computed.axis_stroke_width,
fill: color.clone(),
fill_opacity: None,
stroke: Some(Color::from("white")),
stroke_width: Some(computed.axis_stroke_width),
});
}
}
}
}
pub fn render_roc(roc: RocPlot, layout: Layout) -> Scene {
let plots = vec![crate::render::plots::Plot::Roc(roc)];
render_multiple(plots, layout)
}
fn add_pr(pr: &PrPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::pr::{compute_pr_group, PrPoint};
use crate::render::palette::Palette;
let cat10 = Palette::category10();
let n_groups = pr.groups.len();
let computed_groups: Vec<_> = pr.groups.iter().map(compute_pr_group).collect();
if pr.show_baseline {
let prevalence = if computed_groups.is_empty() {
0.5
} else {
computed_groups.iter().map(|g| g.prevalence).sum::<f64>() / computed_groups.len() as f64
};
scene.add(Primitive::Line {
x1: computed.map_x(0.0),
y1: computed.map_y(prevalence),
x2: computed.map_x(1.0),
y2: computed.map_y(prevalence),
stroke: Color::from(&pr.baseline_color),
stroke_width: computed.axis_stroke_width,
stroke_dasharray: Some(pr.baseline_dasharray.clone()),
});
}
for (i, (group, rc)) in pr.groups.iter().zip(computed_groups.iter()).enumerate() {
let color_str: &str = group.color.as_deref().unwrap_or_else(|| {
if n_groups == 1 {
&pr.color
} else {
&cat10[i % cat10.len()]
}
});
let color = Color::from(color_str);
if rc.points.is_empty() {
continue;
}
let pts: Vec<(f64, f64)> = rc
.points
.iter()
.map(|pt: &PrPoint| (computed.map_x(pt.recall), computed.map_y(pt.precision)))
.collect();
if !pts.is_empty() {
let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
for &(x, y) in pts.iter().skip(1) {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: color.clone(),
stroke_width: group.line_width,
opacity: None,
stroke_dasharray: group.dasharray.clone(),
})));
}
if let Some(opt_idx) = rc.optimal_idx {
if let Some(opt_pt) = rc.points.get(opt_idx) {
let cx = computed.map_x(opt_pt.recall);
let cy = computed.map_y(opt_pt.precision);
scene.add(Primitive::Circle {
cx,
cy,
r: 5.0 * computed.axis_stroke_width,
fill: color.clone(),
fill_opacity: None,
stroke: Some(Color::from("white")),
stroke_width: Some(computed.axis_stroke_width),
});
}
}
}
}
pub fn render_pr(pr: PrPlot, layout: Layout) -> Scene {
let plots = vec![crate::render::plots::Plot::Pr(pr)];
render_multiple(plots, layout)
}
fn add_slope(sp: &crate::plot::slope::SlopePlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::slope::SlopeValueFormat;
let n = sp.points.len();
if n == 0 {
return;
}
for (i, pt) in sp.points.iter().enumerate() {
let y_data = (n - i) as f64;
let py = computed.map_y(y_data);
let px_before = computed.map_x(pt.before);
let px_after = computed.map_x(pt.after);
let color_str: &str = if let Some(ref gc) = sp.group_colors {
gc.get(i).map(|s| s.as_str()).unwrap_or(&sp.color)
} else if sp.color_by_direction {
if pt.after > pt.before {
&sp.color_up
} else if pt.after < pt.before {
&sp.color_down
} else {
&sp.color_flat
}
} else {
&sp.color
};
let color = Color::from(color_str);
let d = format!(
"M {},{} L {},{}",
round2(px_before),
round2(py),
round2(px_after),
round2(py)
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: color.clone(),
stroke_width: sp.line_width,
opacity: Some(sp.line_opacity),
stroke_dasharray: None,
})));
scene.add(Primitive::Circle {
cx: px_before,
cy: py,
r: sp.dot_radius,
fill: color.clone(),
fill_opacity: if sp.dot_opacity < 1.0 {
Some(sp.dot_opacity)
} else {
None
},
stroke: Some(Color::from("white")),
stroke_width: Some(1.5),
});
scene.add(Primitive::Circle {
cx: px_after,
cy: py,
r: sp.dot_radius,
fill: color.clone(),
fill_opacity: if sp.dot_opacity < 1.0 {
Some(sp.dot_opacity)
} else {
None
},
stroke: Some(Color::from("white")),
stroke_width: Some(1.5),
});
if sp.show_values {
let fmt_val = |v: f64| -> String {
match &sp.value_format {
SlopeValueFormat::Auto => {
if v.fract().abs() < 1e-9 {
format!("{:.0}", v)
} else {
let s = format!("{:.2}", v);
let s = s.trim_end_matches('0');
let s = s.trim_end_matches('.');
s.to_string()
}
}
SlopeValueFormat::Fixed(prec) => format!("{:.*}", prec, v),
SlopeValueFormat::Integer => format!("{:.0}", v),
}
};
let label_y = py + computed.body_size as f64 * 0.35;
let label_size = (computed.body_size as f64 * 0.75) as u32;
let gap = sp.dot_radius + 3.0;
let (before_x, before_anchor, after_x, after_anchor) = if px_before <= px_after {
(
px_before - gap,
TextAnchor::End,
px_after + gap,
TextAnchor::Start,
)
} else {
(
px_before + gap,
TextAnchor::Start,
px_after - gap,
TextAnchor::End,
)
};
scene.add(Primitive::Text {
x: before_x,
y: label_y,
content: fmt_val(pt.before),
size: label_size,
anchor: before_anchor,
bold: false,
rotate: None,
color: None,
});
scene.add(Primitive::Text {
x: after_x,
y: label_y,
content: fmt_val(pt.after),
size: label_size,
anchor: after_anchor,
bold: false,
rotate: None,
color: None,
});
}
}
if let Some(ref bl) = sp.before_label {
let n_pts = sp.points.len() as f64;
let mean_before_px = if n_pts > 0.0 {
computed.map_x(sp.points.iter().map(|p| p.before).sum::<f64>() / n_pts)
} else {
computed.map_x(0.0)
};
scene.add(Primitive::Text {
x: mean_before_px,
y: computed.margin_top - 8.0,
content: bl.clone(),
size: (computed.label_size as f64 * 0.85) as u32,
anchor: TextAnchor::Middle,
bold: true,
rotate: None,
color: None,
});
}
if let Some(ref al) = sp.after_label {
let n_pts = sp.points.len() as f64;
let mean_after_px = if n_pts > 0.0 {
computed.map_x(sp.points.iter().map(|p| p.after).sum::<f64>() / n_pts)
} else {
computed.map_x(0.0)
};
scene.add(Primitive::Text {
x: mean_after_px,
y: computed.margin_top - 8.0,
content: al.clone(),
size: (computed.label_size as f64 * 0.85) as u32,
anchor: TextAnchor::Middle,
bold: true,
rotate: None,
color: None,
});
}
}
pub fn render_slope(sp: SlopePlot, layout: Layout) -> Scene {
let plots = vec![crate::render::plots::Plot::Slope(sp)];
render_multiple(plots, layout)
}
pub fn render_venn(vp: VennPlot, layout: Layout) -> Scene {
let plots = vec![crate::render::plots::Plot::Venn(vp)];
render_multiple(plots, layout)
}
pub fn render_parallel(pp: ParallelPlot, layout: Layout) -> Scene {
let plots = vec![crate::render::plots::Plot::Parallel(pp)];
render_multiple(plots, layout)
}
fn add_parallel(pp: &ParallelPlot, scene: &mut Scene, computed: &ComputedLayout) {
let n_axes = pp.axis_names.len();
if n_axes < 2 {
return;
}
if pp.rows.is_empty() {
return;
}
let mut axis_min = vec![f64::INFINITY; n_axes];
let mut axis_max = vec![f64::NEG_INFINITY; n_axes];
for row in &pp.rows {
for (ai, &v) in row.values.iter().enumerate().take(n_axes) {
if v < axis_min[ai] {
axis_min[ai] = v;
}
if v > axis_max[ai] {
axis_max[ai] = v;
}
}
}
for ai in 0..n_axes {
if (axis_max[ai] - axis_min[ai]).abs() < 1e-12 {
axis_min[ai] -= 1.0;
axis_max[ai] += 1.0;
}
}
let global_lo = axis_min.iter().cloned().fold(f64::INFINITY, f64::min);
let global_hi = axis_max.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
const CHAR_W: f64 = 0.62; const MIN_GAP: f64 = 10.0; const LABEL_OFF: f64 = 12.0;
let nominal_tick_size = (computed.body_size as f64 * 0.85) as u32;
let label_chars = |i: usize| -> f64 {
let (lo, hi) = if pp.normalize {
(axis_min[i], axis_max[i])
} else {
(global_lo, global_hi)
};
format_tick_value(lo).len().max(format_tick_value(hi).len()) as f64
};
let base_h_inset = 30.0_f64;
let avail_w = (computed.width - computed.margin_left - computed.margin_right).max(1.0);
let (h_inset, tick_size) = if n_axes > 1 {
let chars_r = label_chars(n_axes - 2); let chars_l = label_chars(n_axes - 1); let req_step =
(chars_r + chars_l) * nominal_tick_size as f64 * CHAR_W + LABEL_OFF + MIN_GAP;
let step_at_base = (avail_w - 2.0 * base_h_inset) / (n_axes - 1) as f64;
let step_at_zero = avail_w / (n_axes - 1) as f64;
if req_step <= step_at_base {
(base_h_inset, nominal_tick_size)
} else if req_step <= step_at_zero {
let h = (avail_w - req_step * (n_axes - 1) as f64) / 2.0;
(h.max(0.0), nominal_tick_size)
} else {
let ts = ((step_at_zero - LABEL_OFF - MIN_GAP) / ((chars_r + chars_l) * CHAR_W))
.max(6.0) as u32;
(0.0_f64, ts)
}
} else {
(base_h_inset, nominal_tick_size)
};
let v_inset = computed.body_size as f64 + 10.0;
let plot_left = computed.margin_left + h_inset;
let plot_right = computed.width - computed.margin_right - h_inset;
let plot_top = computed.margin_top + v_inset;
let plot_bottom = computed.height - computed.margin_bottom;
let plot_w = (plot_right - plot_left).max(1.0);
let plot_h = (plot_bottom - plot_top).max(1.0);
let axis_x = |i: usize| -> f64 {
if n_axes == 1 {
plot_left + plot_w * 0.5
} else {
plot_left + plot_w * (i as f64) / ((n_axes - 1) as f64)
}
};
let map_val = |ai: usize, v: f64| -> f64 {
let (lo, hi) = if pp.normalize {
(axis_min[ai], axis_max[ai])
} else {
(global_lo, global_hi)
};
let t = (v - lo) / (hi - lo);
let t = if pp.is_inverted(ai) { t } else { 1.0 - t };
plot_top + t * plot_h
};
if pp.show_axis_bands {
let slot_w = if n_axes > 1 {
plot_w / (n_axes as f64 - 1.0)
} else {
plot_w
};
let band_w = (slot_w * 0.5).min(40.0);
for i in 0..n_axes {
let ax = axis_x(i);
scene.add(Primitive::Rect {
x: ax - band_w * 0.5,
y: plot_top,
width: band_w,
height: plot_h,
fill: Color::from("#f5f5f5"),
stroke: None,
stroke_width: None,
opacity: Some(1.0),
});
}
}
const AXIS_COLOR: &str = "#555555";
const INVERTED_COLOR: &str = "#d46000";
for i in 0..n_axes {
let ax = axis_x(i);
let color = if pp.is_inverted(i) {
INVERTED_COLOR
} else {
AXIS_COLOR
};
scene.add(Primitive::Line {
x1: ax,
y1: plot_top,
x2: ax,
y2: plot_bottom,
stroke: Color::from(color),
stroke_width: 1.5,
stroke_dasharray: None,
});
}
let label_anchor = |i: usize| -> TextAnchor {
if i == 0 {
TextAnchor::Start
} else if i == n_axes - 1 {
TextAnchor::End
} else {
TextAnchor::Middle
}
};
let label_x = |i: usize| -> f64 { axis_x(i) };
let label_size = computed.body_size;
for (i, name) in pp.axis_names.iter().enumerate() {
let label_color = if pp.is_inverted(i) {
Some(Color::from(INVERTED_COLOR))
} else {
None
};
scene.add(Primitive::Text {
x: label_x(i),
y: plot_top - 8.0,
content: name.clone(),
size: label_size,
anchor: label_anchor(i),
rotate: None,
bold: true,
color: label_color,
});
if pp.is_inverted(i) {
let sym_x = axis_x(i);
let sym_y = plot_top + label_size as f64 * 1.0;
let sym_size = label_size + 2;
scene.add(Primitive::Text {
x: sym_x,
y: sym_y,
content: "▼".to_string(),
size: sym_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: Some(Color::from(INVERTED_COLOR)),
});
}
}
if pp.show_axis_ticks {
let min_px_per_tick = tick_size as f64 * 2.2;
let max_ticks_by_space = (plot_h / min_px_per_tick) as usize;
let n_ticks = pp.axis_ticks.min(max_ticks_by_space).max(1);
for i in 0..n_axes {
let ax = axis_x(i);
let (lo, hi) = if pp.normalize {
(axis_min[i], axis_max[i])
} else {
(global_lo, global_hi)
};
let is_last = i == n_axes - 1;
let tick_dx = if is_last { -4.0_f64 } else { 4.0_f64 };
let label_x = if is_last { ax - 6.0 } else { ax + 6.0 };
let tick_color = if pp.is_inverted(i) {
INVERTED_COLOR
} else {
AXIS_COLOR
};
for t in 0..=n_ticks {
let frac = t as f64 / n_ticks as f64;
let val = lo + frac * (hi - lo);
let py = map_val(i, val);
scene.add(Primitive::Line {
x1: ax,
y1: py,
x2: ax + tick_dx,
y2: py,
stroke: Color::from(tick_color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Text {
x: label_x,
y: py + tick_size as f64 * 0.35,
content: format_tick_value(val),
size: tick_size,
anchor: if is_last {
TextAnchor::End
} else {
TextAnchor::Start
},
rotate: None,
bold: false,
color: None,
});
}
}
}
let groups = pp.groups();
let has_groups = !groups.is_empty();
let group_idx = |g: &str| -> usize { groups.iter().position(|x| x == g).unwrap_or(0) };
let build_path = |pts: &[(f64, f64)]| -> String {
let mut d = String::new();
if pts.is_empty() {
return d;
}
let _ = write!(d, "M {:.2},{:.2}", pts[0].0, pts[0].1);
if pp.curved {
for w in pts.windows(2) {
let (x0, y0) = w[0];
let (x1, y1) = w[1];
let mx = (x0 + x1) * 0.5;
let _ = write!(
d,
" C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
mx, y0, mx, y1, x1, y1
);
}
} else {
for &(px, py) in &pts[1..] {
let _ = write!(d, " L {:.2},{:.2}", px, py);
}
}
d
};
for row in &pp.rows {
if row.values.len() < n_axes {
continue;
}
let color_str = if has_groups {
if let Some(ref g) = row.group {
pp.color_for_group_idx(group_idx(g))
} else {
pp.color.clone()
}
} else {
pp.color.clone()
};
let pts: Vec<(f64, f64)> = row
.values
.iter()
.enumerate()
.take(n_axes)
.map(|(ai, &v)| (axis_x(ai), map_val(ai, v)))
.collect();
scene.add(Primitive::Path(Box::new(PathData {
d: build_path(&pts),
fill: None,
stroke: Color::from(color_str.as_str()),
stroke_width: pp.stroke_width,
opacity: Some(pp.opacity),
stroke_dasharray: None,
})));
}
if pp.show_mean && has_groups {
for (gi, g) in groups.iter().enumerate() {
let group_rows: Vec<&ParallelRow> = pp
.rows
.iter()
.filter(|r| r.group.as_deref() == Some(g.as_str()) && r.values.len() >= n_axes)
.collect();
if group_rows.is_empty() {
continue;
}
let means: Vec<f64> = (0..n_axes)
.map(|ai| {
let sum: f64 = group_rows.iter().map(|r| r.values[ai]).sum();
sum / group_rows.len() as f64
})
.collect();
let pts: Vec<(f64, f64)> = means
.iter()
.enumerate()
.map(|(ai, &v)| (axis_x(ai), map_val(ai, v)))
.collect();
let color_str = pp.color_for_group_idx(gi);
scene.add(Primitive::Path(Box::new(PathData {
d: build_path(&pts),
fill: None,
stroke: Color::from(color_str.as_str()),
stroke_width: pp.mean_stroke_width,
opacity: Some(1.0),
stroke_dasharray: None,
})));
}
}
}
fn format_tick_value(val: f64) -> String {
if val == 0.0 {
return "0".to_string();
}
if val.abs() >= 10_000.0 || (val.abs() < 0.01 && val != 0.0) {
format!("{val:.2e}")
} else {
let s = format!("{val:.3}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
}
fn ellipse_path(cx: f64, cy: f64, rx: f64, ry: f64, angle_deg: f64) -> String {
let theta = angle_deg.to_radians();
let (cos_t, sin_t) = (theta.cos(), theta.sin());
let pts: Vec<(f64, f64)> = (0..=72)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 72.0;
let lx = rx * a.cos();
let ly = ry * a.sin();
(cx + lx * cos_t - ly * sin_t, cy + lx * sin_t + ly * cos_t)
})
.collect();
let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
for p in &pts[1..] {
d.push_str(&format!(" L {},{}", round2(p.0), round2(p.1)));
}
d.push_str(" Z");
d
}
fn point_in_ellipse(px: f64, py: f64, cx: f64, cy: f64, rx: f64, ry: f64, angle_deg: f64) -> bool {
let theta = angle_deg.to_radians();
let dx = px - cx;
let dy = py - cy;
let lx = dx * theta.cos() + dy * theta.sin();
let ly = -dx * theta.sin() + dy * theta.cos();
(lx / rx).powi(2) + (ly / ry).powi(2) <= 1.0
}
fn point_in_circle(px: f64, py: f64, cx: f64, cy: f64, r: f64) -> bool {
let dx = px - cx;
let dy = py - cy;
dx * dx + dy * dy <= r * r
}
fn lens_area(r1: f64, r2: f64, d: f64) -> f64 {
if d <= (r1 - r2).abs() {
return std::f64::consts::PI * r1.min(r2).powi(2);
}
if d >= r1 + r2 {
return 0.0;
}
let a1 = ((d * d + r1 * r1 - r2 * r2) / (2.0 * d * r1))
.clamp(-1.0, 1.0)
.acos();
let a2 = ((d * d + r2 * r2 - r1 * r1) / (2.0 * d * r2))
.clamp(-1.0, 1.0)
.acos();
let tri = 0.5
* ((r1 + r2 + d) * (-d + r1 + r2) * (d - r1 + r2) * (d + r1 - r2))
.max(0.0)
.sqrt();
r1 * r1 * a1 + r2 * r2 * a2 - tri
}
fn solve_distance_2set(r1: f64, r2: f64, target_overlap: f64) -> f64 {
let (mut lo, mut hi) = ((r1 - r2).abs(), r1 + r2);
let max_area = std::f64::consts::PI * r1.min(r2).powi(2);
let target = target_overlap.clamp(0.0, max_area);
for _ in 0..64 {
let mid = (lo + hi) / 2.0;
if lens_area(r1, r2, mid) > target {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
}
fn point_bitmask(px: f64, py: f64, shapes: &[(f64, f64, f64, f64, f64)]) -> u8 {
let mut mask = 0u8;
for (i, &(cx, cy, rx, ry, angle)) in shapes.iter().enumerate() {
if (rx - ry).abs() < 1e-9 {
if point_in_circle(px, py, cx, cy, rx) {
mask |= 1 << i;
}
} else {
if point_in_ellipse(px, py, cx, cy, rx, ry, angle) {
mask |= 1 << i;
}
}
}
mask
}
fn region_centroids(
shapes: &[(f64, f64, f64, f64, f64)],
x0: f64,
y0: f64,
x1: f64,
y1: f64,
min_samples: usize,
) -> std::collections::HashMap<u8, (f64, f64)> {
let steps = 80usize;
let mut sums: std::collections::HashMap<u8, (f64, f64, usize)> =
std::collections::HashMap::new();
for iy in 0..steps {
let py = y0 + (iy as f64 + 0.5) / steps as f64 * (y1 - y0);
for ix in 0..steps {
let px = x0 + (ix as f64 + 0.5) / steps as f64 * (x1 - x0);
let mask = point_bitmask(px, py, shapes);
if mask != 0 {
let e = sums.entry(mask).or_insert((0.0, 0.0, 0));
e.0 += px;
e.1 += py;
e.2 += 1;
}
}
}
sums.into_iter()
.filter(|(_, (_, _, cnt))| *cnt >= min_samples)
.map(|(mask, (sx, sy, cnt))| (mask, (sx / cnt as f64, sy / cnt as f64)))
.collect()
}
fn draw_inline_indicators(
vp: &VennPlot,
scene: &mut Scene,
mask: u8,
lx: f64,
ly: f64,
label_size: u32,
n: usize,
) {
if !vp.show_set_indicators || (!vp.show_counts && !vp.show_percentages) {
return;
}
let in_sets: Vec<usize> = (0..n).filter(|&i| mask & (1u8 << i) != 0).collect();
if in_sets.is_empty() {
return;
}
let dot_r = (label_size as f64 * 0.38).max(2.5_f64);
let dot_stride = dot_r * 2.6;
let total_dots_w = (in_sets.len() - 1) as f64 * dot_stride + dot_r * 2.0;
let dot_start_x = lx - total_dots_w / 2.0 + dot_r;
let first_text_y = if vp.show_counts {
ly - label_size as f64 * 0.6
} else {
ly
};
let dot_cy = first_text_y - label_size as f64 * 0.72 - dot_r - 2.0;
for (k, &set_i) in in_sets.iter().enumerate() {
let color = vp.color_for(set_i);
scene.add(Primitive::Circle {
cx: dot_start_x + k as f64 * dot_stride,
cy: dot_cy,
r: dot_r,
fill: Color::from(color.as_str()),
fill_opacity: None,
stroke: Some(Color::from("#ffffff")),
stroke_width: Some(0.6),
});
}
}
#[allow(clippy::too_many_arguments)]
fn add_venn(vp: &VennPlot, scene: &mut Scene, computed: &ComputedLayout) {
let n = vp.sets.len();
if n == 0 || n > 4 {
return;
}
let cx = computed.margin_left + computed.plot_width() / 2.0;
let cy = computed.margin_top + computed.plot_height() / 2.0;
let avail = computed.plot_width().min(computed.plot_height()) / 2.0;
let shapes: Vec<(f64, f64, f64, f64, f64)> = match n {
2 => {
if vp.proportional {
let sz: Vec<f64> = vp
.sets
.iter()
.map(|s| {
s.size
.unwrap_or_else(|| s.elements.as_deref().map_or(0, |e| e.len()))
as f64
})
.collect();
let max_sz = sz.iter().cloned().fold(0.0f64, f64::max).max(1.0);
let scale = avail / (max_sz / std::f64::consts::PI).sqrt() / 1.5;
let r0 = (sz[0] / std::f64::consts::PI).sqrt() * scale;
let r1 = (sz[1] / std::f64::consts::PI).sqrt() * scale;
let regions = vp.region_sizes();
let overlap = regions.get(&0b11).copied().unwrap_or(0) as f64;
let overlap_area = overlap / max_sz * std::f64::consts::PI * r0.min(r1).powi(2);
let d2 = solve_distance_2set(r0, r1, overlap_area);
vec![
(cx - d2 / 2.0, cy, r0, r0, 0.0),
(cx + d2 / 2.0, cy, r1, r1, 0.0),
]
} else {
let r = avail * 0.55;
vec![(cx - r * 0.5, cy, r, r, 0.0), (cx + r * 0.5, cy, r, r, 0.0)]
}
}
3 => {
if vp.proportional {
let sz: Vec<f64> = vp
.sets
.iter()
.map(|s| {
s.size
.unwrap_or_else(|| s.elements.as_deref().map_or(0, |e| e.len()))
as f64
})
.collect();
let max_sz = sz.iter().cloned().fold(0.0f64, f64::max).max(1.0);
let scale = avail * 0.9 / ((max_sz / std::f64::consts::PI).sqrt() * 2.0);
let r: Vec<f64> = sz
.iter()
.map(|&s| (s / std::f64::consts::PI).sqrt() * scale)
.collect();
let regions = vp.region_sizes();
let ov_ab = regions
.iter()
.filter(|(&m, _)| m & 0b011 == 0b011)
.map(|(_, &v)| v)
.sum::<usize>() as f64;
let ov_ac = regions
.iter()
.filter(|(&m, _)| m & 0b101 == 0b101)
.map(|(_, &v)| v)
.sum::<usize>() as f64;
let ov_bc = regions
.iter()
.filter(|(&m, _)| m & 0b110 == 0b110)
.map(|(_, &v)| v)
.sum::<usize>() as f64;
let area = |ov: f64, ri: f64, rj: f64| -> f64 {
ov / max_sz.max(1.0) * std::f64::consts::PI * ri.min(rj).powi(2)
};
let d_ab = solve_distance_2set(r[0], r[1], area(ov_ab, r[0], r[1]));
let d_ac = solve_distance_2set(r[0], r[2], area(ov_ac, r[0], r[2]));
let d_bc = solve_distance_2set(r[1], r[2], area(ov_bc, r[1], r[2]));
let d_ab = d_ab.min((r[0] + r[1]) * 0.9);
let d_ac = d_ac.min((r[0] + r[2]) * 0.9);
let d_bc = d_bc.min((r[1] + r[2]) * 0.9);
let ay = cy - d_ab.max(d_ac) * 0.4;
let ax = cx;
let bx = ax - d_ab * (60.0f64.to_radians().sin()) / 2.0 * 1.2;
let by_raw = ay + (d_ab * d_ab - (bx - ax).powi(2)).max(0.0).sqrt();
let by = by_raw.min(cy + avail * 0.8);
let ab_dist = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt().max(1e-9);
let cos_c = ((d_ac * d_ac + ab_dist * ab_dist - d_bc * d_bc)
/ (2.0 * d_ac * ab_dist))
.clamp(-1.0, 1.0);
let angle_ab = (by - ay).atan2(bx - ax);
let angle_c = angle_ab - cos_c.acos();
let ccx = ax + d_ac * angle_c.cos();
let ccy = ay + d_ac * angle_c.sin();
vec![
(ax, ay, r[0], r[0], 0.0),
(bx, by, r[1], r[1], 0.0),
(ccx, ccy, r[2], r[2], 0.0),
]
} else {
let r = avail * 0.55;
let offset = r * 0.55;
let sin60 = (60.0f64.to_radians()).sin();
let cos60 = (60.0f64.to_radians()).cos();
vec![
(cx, cy - offset, r, r, 0.0),
(cx - offset * sin60, cy + offset * cos60, r, r, 0.0),
(cx + offset * sin60, cy + offset * cos60, r, r, 0.0),
]
}
}
4 => {
let rx = avail * 0.72;
let ry = avail * 0.44;
vec![
(cx - avail * 0.20, cy - avail * 0.05, rx, ry, 45.0),
(cx + avail * 0.20, cy - avail * 0.05, rx, ry, 135.0),
(cx - avail * 0.20, cy + avail * 0.05, rx, ry, 135.0),
(cx + avail * 0.20, cy + avail * 0.05, rx, ry, 45.0),
]
}
_ => return,
};
for (i, &(scx, scy, srx, sry, angle)) in shapes.iter().enumerate() {
let color = vp.color_for(i);
let c = Color::from(color.as_str());
let is_circle = (srx - sry).abs() < 1e-9;
if is_circle {
scene.add(Primitive::Circle {
cx: scx,
cy: scy,
r: srx,
fill: c.clone(),
fill_opacity: Some(vp.fill_opacity),
stroke: Some(c),
stroke_width: Some(vp.stroke_width),
});
} else {
let d = ellipse_path(scx, scy, srx, sry, angle);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(c.clone()),
stroke: c,
stroke_width: vp.stroke_width,
opacity: Some(vp.fill_opacity),
stroke_dasharray: None,
})));
}
}
let region_map = vp.region_sizes();
let total: usize = region_map.values().sum::<usize>().max(1);
let x0 = computed.margin_left;
let y0 = computed.margin_top;
let x1 = computed.margin_left + computed.plot_width();
let y1 = computed.margin_top + computed.plot_height();
let min_samples = if n == 4 { 15 } else { 5 };
let centroids = region_centroids(&shapes, x0, y0, x1, y1, min_samples);
let total_masks = 1u8 << n;
let label_size = if n == 4 {
((computed.body_size as f64 * 0.78) as u32).max(8)
} else {
computed.body_size
};
if vp.leader_lines {
use std::f64::consts::TAU;
let min_label_dist = if n == 4 {
label_size as f64 * 3.2
} else {
label_size as f64 * 2.0
};
let mut masks_ordered: Vec<u8> = (1..total_masks).collect();
masks_ordered.sort_by_key(|&m| std::cmp::Reverse(region_map.get(&m).copied().unwrap_or(0)));
let mut placed: Vec<(f64, f64)> = Vec::new();
let mut rejected: Vec<(u8, f64, f64)> = Vec::new();
for &mask in &masks_ordered {
let Some(&(lx, ly)) = centroids.get(&mask) else {
continue;
};
let blocked = placed.iter().any(|&(px, py)| {
let dx = lx - px;
let dy = ly - py;
(dx * dx + dy * dy).sqrt() < min_label_dist
});
if blocked {
rejected.push((mask, lx, ly));
continue;
}
placed.push((lx, ly));
let count = region_map.get(&mask).copied().unwrap_or(0);
let count_str = count.to_string();
let pct_str = format!("({:.1}%)", count as f64 / total as f64 * 100.0);
draw_inline_indicators(vp, scene, mask, lx, ly, label_size, n);
match (vp.show_counts, vp.show_percentages) {
(true, true) => {
scene.add(Primitive::Text {
x: lx,
y: ly - label_size as f64 * 0.6,
content: count_str,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
scene.add(Primitive::Text {
x: lx,
y: ly + label_size as f64 * 0.8,
content: pct_str,
size: label_size.saturating_sub(2),
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
(true, false) => {
scene.add(Primitive::Text {
x: lx,
y: ly,
content: count_str,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
(false, true) => {
scene.add(Primitive::Text {
x: lx,
y: ly,
content: pct_str,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
(false, false) => {}
}
}
#[allow(clippy::type_complexity)]
let mut leader_entries: Vec<(u8, (f64, f64), (f64, f64), f64)> = Vec::new();
for &(mask, cen_x, cen_y) in &rejected {
let raw_dx = cen_x - cx;
let raw_dy = cen_y - cy;
let dist = (raw_dx * raw_dx + raw_dy * raw_dy).sqrt();
let (dir_x, dir_y) = if dist > 1e-9 {
(raw_dx / dist, raw_dy / dist)
} else {
let a = leader_entries.len() as f64 * TAU / rejected.len().max(1) as f64;
(a.cos(), a.sin())
};
let step = 3.0_f64;
let mut mx = cen_x;
let mut my = cen_y;
for _ in 0..400 {
mx += dir_x * step;
my += dir_y * step;
if point_bitmask(mx, my, &shapes) == 0 {
break;
}
}
let anchor_x = mx + dir_x * 10.0;
let anchor_y = my + dir_y * 10.0;
let angle = (anchor_y - cy).atan2(anchor_x - cx);
leader_entries.push((mask, (cen_x, cen_y), (anchor_x, anchor_y), angle));
}
if leader_entries.is_empty() {
} else {
leader_entries
.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal));
let mut angles: Vec<f64> = leader_entries.iter().map(|e| e.3).collect();
let label_r = leader_entries
.iter()
.map(|(_, _, (ax, ay), _)| ((ax - cx).powi(2) + (ay - cy).powi(2)).sqrt())
.fold(0.0_f64, f64::max);
let min_gap = 0.28_f64; for _ in 0..5 {
let m = angles.len();
if m < 2 {
break;
}
for i in 0..m {
let j = (i + 1) % m;
let raw_gap = if j == 0 {
angles[0] + TAU - angles[m - 1]
} else {
angles[j] - angles[i]
};
if raw_gap < min_gap {
let push = (min_gap - raw_gap) / 2.0;
if j == 0 {
angles[m - 1] += push;
angles[0] -= push;
} else {
angles[i] -= push;
angles[j] += push;
}
}
}
}
let edge_margin = 8.0_f64;
let left_bound = computed.margin_left + edge_margin;
let right_bound = computed.margin_left + computed.plot_width() - edge_margin;
let top_bound = computed.margin_top + edge_margin;
let bottom_bound = computed.margin_top + computed.plot_height() - edge_margin;
for (idx, (mask, (cen_x, cen_y), _, _)) in leader_entries.iter().enumerate() {
let angle = angles[idx];
let lx = (cx + label_r * angle.cos()).clamp(left_bound, right_bound);
let ly = (cy + label_r * angle.sin()).clamp(top_bound, bottom_bound);
let ldx = lx - cen_x;
let ldy = ly - cen_y;
let ldist = (ldx * ldx + ldy * ldy).sqrt().max(1.0);
let (lnx, lny) = (ldx / ldist, ldy / ldist);
scene.add(Primitive::Line {
x1: *cen_x,
y1: *cen_y,
x2: lx - lnx * 6.0,
y2: ly - lny * 6.0,
stroke: Color::from("#aaaaaa"),
stroke_width: 1.0,
stroke_dasharray: None,
});
let in_sets: Vec<usize> = (0..n).filter(|&i| mask & (1u8 << i) != 0).collect();
let count = region_map.get(mask).copied().unwrap_or(0);
let count_str = count.to_string();
let pct_str = format!("({:.1}%)", count as f64 / total as f64 * 100.0);
let dot_r = (label_size as f64 * 0.42).max(3.0);
let dot_stride = dot_r * 2.6;
let total_dots_w = if vp.show_set_indicators {
in_sets.len() as f64 * dot_stride
} else {
0.0
};
let count_text_w = count_str.len() as f64 * label_size as f64 * 0.62;
let text_gap = if vp.show_set_indicators { 4.0 } else { 0.0 };
let nx = lnx; let group_start_x = if nx > 0.15 {
lx
} else if nx < -0.15 {
lx - total_dots_w - text_gap - count_text_w
} else {
lx - (total_dots_w + text_gap + count_text_w) / 2.0
};
if vp.show_set_indicators {
for (k, &set_i) in in_sets.iter().enumerate() {
let color = vp.color_for(set_i);
let dot_cx = group_start_x + dot_r + k as f64 * dot_stride;
scene.add(Primitive::Circle {
cx: dot_cx,
cy: ly,
r: dot_r,
fill: Color::from(color.as_str()),
fill_opacity: None,
stroke: Some(Color::from("#ffffff")),
stroke_width: Some(0.8),
});
}
}
let text_x = group_start_x + total_dots_w + text_gap;
let text_y = ly + label_size as f64 * 0.35;
if vp.show_counts {
scene.add(Primitive::Text {
x: text_x,
y: text_y,
content: count_str,
size: label_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
if vp.show_percentages {
scene.add(Primitive::Text {
x: text_x,
y: text_y + label_size as f64 * 1.3,
content: pct_str,
size: label_size.saturating_sub(2),
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
}
} else {
let min_label_dist = if n == 4 { label_size as f64 * 3.2 } else { 0.0 };
let mut masks_ordered: Vec<u8> = (1..total_masks).collect();
if n == 4 {
masks_ordered
.sort_by_key(|&m| std::cmp::Reverse(region_map.get(&m).copied().unwrap_or(0)));
}
let mut placed: Vec<(f64, f64)> = Vec::new();
for mask in masks_ordered {
let count = region_map.get(&mask).copied().unwrap_or(0);
if let Some(&(lx, ly)) = centroids.get(&mask) {
if min_label_dist > 0.0 {
let blocked = placed.iter().any(|&(px, py)| {
let dx = lx - px;
let dy = ly - py;
(dx * dx + dy * dy).sqrt() < min_label_dist
});
if blocked {
continue;
}
}
placed.push((lx, ly));
let count_str = count.to_string();
let pct_str = format!("({:.1}%)", count as f64 / total as f64 * 100.0);
draw_inline_indicators(vp, scene, mask, lx, ly, label_size, n);
match (vp.show_counts, vp.show_percentages) {
(true, true) => {
scene.add(Primitive::Text {
x: lx,
y: ly - label_size as f64 * 0.6,
content: count_str,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
scene.add(Primitive::Text {
x: lx,
y: ly + label_size as f64 * 0.8,
content: pct_str,
size: label_size.saturating_sub(2),
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
(true, false) => {
scene.add(Primitive::Text {
x: lx,
y: ly,
content: count_str,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
(false, true) => {
scene.add(Primitive::Text {
x: lx,
y: ly,
content: pct_str,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
(false, false) => {}
}
}
}
}
if vp.show_set_labels {
let label_size_big = ((computed.body_size as f64 * 1.1) as u32).max(computed.body_size);
let label_margin = label_size_big as f64 + 6.0;
for (i, set) in vp.sets.iter().enumerate() {
let (ecx, ecy, rx, ry, angle_deg) = shapes[i];
let (raw_dx, raw_dy) = (ecx - cx, ecy - cy);
let (raw_dx, raw_dy) = if n == 2 {
(raw_dx, raw_dy - raw_dx.abs() * 0.45)
} else {
(raw_dx, raw_dy)
};
let dist = (raw_dx * raw_dx + raw_dy * raw_dy).sqrt();
let (nx, ny) = if dist > 1e-9 {
(raw_dx / dist, raw_dy / dist)
} else {
let angle = i as f64 * std::f64::consts::TAU / n as f64;
(angle.cos(), -angle.sin())
};
let (bx, by) = if (rx - ry).abs() < 1e-9 {
(ecx + nx * rx, ecy + ny * ry)
} else {
let theta = angle_deg.to_radians();
let a = nx * theta.cos() + ny * theta.sin();
let b = -nx * theta.sin() + ny * theta.cos();
let t = (ry * b).atan2(rx * a);
let bx = ecx + rx * t.cos() * theta.cos() - ry * t.sin() * theta.sin();
let by = ecy + rx * t.cos() * theta.sin() + ry * t.sin() * theta.cos();
(bx, by)
};
let label_x = bx + nx * label_margin;
let label_y = by + ny * label_margin + label_size_big as f64 * 0.35;
let anchor = if nx < -0.25 {
TextAnchor::End
} else if nx > 0.25 {
TextAnchor::Start
} else {
TextAnchor::Middle
};
scene.add(Primitive::Text {
x: label_x,
y: label_y,
content: set.label.clone(),
size: label_size_big,
anchor,
rotate: None,
bold: true,
color: None,
});
}
}
if vp.proportional && vp.show_loss {
let mut grid_counts: std::collections::HashMap<u8, usize> =
std::collections::HashMap::new();
for iy in 0..80usize {
let py = y0 + (iy as f64 + 0.5) / 80.0 * (y1 - y0);
for ix in 0..80usize {
let px = x0 + (ix as f64 + 0.5) / 80.0 * (x1 - x0);
let m = point_bitmask(px, py, &shapes);
if m != 0 {
*grid_counts.entry(m).or_insert(0) += 1;
}
}
}
let total_grid = grid_counts.values().sum::<usize>().max(1) as f64;
let total_target = region_map.values().sum::<usize>().max(1) as f64;
let mut sq_num = 0.0_f64;
let mut sq_den = 0.0_f64;
for mask in 1..total_masks {
let ai = grid_counts.get(&mask).copied().unwrap_or(0) as f64 / total_grid;
let ti = region_map.get(&mask).copied().unwrap_or(0) as f64 / total_target;
sq_num += (ai - ti).powi(2);
sq_den += ti.powi(2);
}
let stress = if sq_den > 1e-12 {
(sq_num / sq_den).sqrt()
} else {
0.0
};
let text_size = 10u32;
let label_text = "Layout stress";
let value_text = format!("{stress:.3}");
let pad_x = 7.0_f64;
let pad_y = 5.0_f64;
let row_h = text_size as f64 * 1.4;
let box_w = (label_text.len().max(value_text.len()) as f64 * text_size as f64 * 0.62
+ pad_x * 2.0)
.max(90.0_f64);
let box_h = row_h * 2.0 + pad_y * 2.0;
let box_x = computed.margin_left + computed.plot_width() - 8.0 - box_w;
let box_y = computed.margin_top + computed.plot_height() - 8.0 - box_h;
scene.add(Primitive::Rect {
x: box_x,
y: box_y,
width: box_w,
height: box_h,
fill: Color::from("#ffffff"),
stroke: Some(Color::from("#cccccc")),
stroke_width: Some(0.8),
opacity: Some(0.92),
});
scene.add(Primitive::Rect {
x: box_x,
y: box_y,
width: box_w,
height: row_h + pad_y,
fill: Color::from("#f0f0f0"),
stroke: None,
stroke_width: None,
opacity: Some(0.92),
});
scene.add(Primitive::Text {
x: box_x + pad_x,
y: box_y + pad_y + text_size as f64 * 0.75,
content: label_text.to_string(),
size: text_size,
anchor: TextAnchor::Start,
rotate: None,
bold: true,
color: None,
});
scene.add(Primitive::Text {
x: box_x + pad_x,
y: box_y + pad_y + row_h + text_size as f64 * 0.75,
content: value_text,
size: text_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
fn add_raincloud(rp: &RaincloudPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
let n = rp.groups.len();
if n == 0 {
return;
}
let cat10 = Palette::category10();
for (i, group) in rp.groups.iter().enumerate() {
if group.values.is_empty() {
continue;
}
let color = rp
.group_colors
.as_ref()
.and_then(|c| c.get(i).map(|s| s.as_str()))
.unwrap_or_else(|| {
if rp.groups.len() > 1 {
&cat10[i]
} else {
&rp.color
}
});
let cloud_sign: f64 = if rp.flip { -1.0 } else { 1.0 };
let rain_sign: f64 = -cloud_sign;
let group_x = (i + 1) as f64;
let cloud_cx = group_x + cloud_sign * rp.cloud_offset;
let rain_cx = group_x + rain_sign * rp.rain_offset;
if rp.show_cloud {
let h = rp.bandwidth.unwrap_or_else(|| {
render_utils::silverman_bandwidth(&group.values) * rp.bandwidth_scale
});
let all_kde = render_utils::simple_kde(&group.values, h, rp.kde_samples);
let data_min = group.values.iter().cloned().fold(f64::INFINITY, f64::min);
let data_max = group
.values
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let kde: Vec<(f64, f64)> = all_kde
.into_iter()
.filter(|(y, _)| *y >= data_min && *y <= data_max)
.collect();
if !kde.is_empty() {
let max_density = kde
.iter()
.map(|(_, d)| *d)
.fold(f64::NEG_INFINITY, f64::max);
if max_density > 0.0 {
let scale = rp.cloud_width / max_density;
let spine_px = computed.map_x(cloud_cx);
let mut path_data = String::with_capacity(kde.len() * 32);
{
let mut rb = ryu::Buffer::new();
for (j, (y_val, density)) in kde.iter().enumerate() {
let py = computed.map_y(*y_val);
let px = spine_px + cloud_sign * density * scale;
path_data.push(if j == 0 { 'M' } else { 'L' });
path_data.push(' ');
path_data.push_str(rb.format(round2(px)));
path_data.push(' ');
path_data.push_str(rb.format(round2(py)));
path_data.push(' ');
}
for (y_val, _) in kde.iter().rev() {
let py = computed.map_y(*y_val);
path_data.push_str("L ");
path_data.push_str(rb.format(round2(spine_px)));
path_data.push(' ');
path_data.push_str(rb.format(round2(py)));
path_data.push(' ');
}
}
path_data.push('Z');
scene.add(Primitive::Path(Box::new(PathData {
d: path_data,
fill: Some(Color::from(color)),
stroke: Color::from(color),
stroke_width: 0.5,
opacity: Some(rp.cloud_alpha),
stroke_dasharray: None,
})));
}
}
}
if rp.show_box {
let mut sorted = group.values.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
let q1 = percentile(&sorted, 25.0);
let q2 = percentile(&sorted, 50.0);
let q3 = percentile(&sorted, 75.0);
let iqr = q3 - q1;
let lower_w = sorted
.iter()
.cloned()
.filter(|v| *v >= q1 - 1.5 * iqr)
.fold(f64::INFINITY, f64::min);
let upper_w = sorted
.iter()
.cloned()
.filter(|v| *v <= q3 + 1.5 * iqr)
.fold(f64::NEG_INFINITY, f64::max);
let box_half_px = computed.plot_width() / n as f64 * rp.box_width / 2.0;
let xmid = computed.map_x(group_x);
let x0 = xmid - box_half_px;
let x1 = xmid + box_half_px;
let yq1 = computed.map_y(q1);
let yq3 = computed.map_y(q3);
let ymed = computed.map_y(q2);
let ylow = computed.map_y(lower_w);
let yhigh = computed.map_y(upper_w);
scene.add(Primitive::Rect {
x: x0,
y: yq3.min(yq1),
width: (x1 - x0).abs(),
height: (yq1 - yq3).abs(),
fill: Color::from(color),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Line {
x1: x0,
y1: ymed,
x2: x1,
y2: ymed,
stroke: Color::from(&computed.theme.box_median),
stroke_width: 1.5,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: xmid,
y1: ylow,
x2: xmid,
y2: yq1,
stroke: Color::from(color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: xmid,
y1: yq3,
x2: xmid,
y2: yhigh,
stroke: Color::from(color),
stroke_width: 1.0,
stroke_dasharray: None,
});
let cap_half = box_half_px * 0.5;
for &y in &[ylow, yhigh] {
scene.add(Primitive::Line {
x1: xmid - cap_half,
y1: y,
x2: xmid + cap_half,
y2: y,
stroke: Color::from(color),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
}
if rp.show_rain {
let style = StripStyle::Strip {
jitter: rp.rain_jitter,
};
add_strip_points(
&group.values,
rain_cx,
&style,
color,
None,
None, rp.rain_size,
rp.seed.wrapping_add(i as u64 * 1000),
Some(rp.rain_alpha),
None,
false,
None,
&group.label,
0,
scene,
computed,
);
}
}
}
fn euclidean_dist_matrix(data: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = data.len();
let mut dm = vec![vec![0.0f64; n]; n];
for i in 0..n {
for j in (i + 1)..n {
let d = data[i]
.iter()
.zip(&data[j])
.map(|(&a, &b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
dm[i][j] = d;
dm[j][i] = d;
}
}
dm
}
fn apply_normalization(data: Vec<Vec<f64>>, norm: &ClustermapNorm) -> Vec<Vec<f64>> {
match norm {
ClustermapNorm::None => data,
ClustermapNorm::RowZScore => data
.into_iter()
.map(|row| {
let n = row.len() as f64;
if n == 0.0 {
return row;
}
let mean = row.iter().sum::<f64>() / n;
let std = (row.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n).sqrt();
if std < f64::EPSILON {
row.iter().map(|_| 0.0).collect()
} else {
row.iter().map(|&v| (v - mean) / std).collect()
}
})
.collect(),
ClustermapNorm::ColZScore => {
if data.is_empty() {
return data;
}
let n_cols = data[0].len();
let n_rows = data.len();
let mut result = data.clone();
for c in 0..n_cols {
let col: Vec<f64> = data.iter().map(|r| r[c]).collect();
let mean = col.iter().sum::<f64>() / n_rows as f64;
let std =
(col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n_rows as f64).sqrt();
for r in 0..n_rows {
result[r][c] = if std < f64::EPSILON {
0.0
} else {
(data[r][c] - mean) / std
};
}
}
result
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_row_dendrogram(
nodes: &[crate::plot::phylo::PhyloNode],
root: usize,
scene: &mut Scene,
_ml: f64,
hm_x: f64,
hm_y: f64,
row_dend_w: f64,
cell_h: f64,
branch_color: &str,
) {
use crate::plot::phylo::post_order_dfs;
let n_nodes = nodes.len();
if n_nodes == 0 {
return;
}
let post_order = post_order_dfs(root, nodes);
let mut pos = vec![0.0f64; n_nodes];
let mut leaf_counter = 0usize;
for &id in &post_order {
if nodes[id].children.is_empty() {
pos[id] = leaf_counter as f64;
leaf_counter += 1;
} else {
let sum: f64 = nodes[id].children.iter().map(|&c| pos[c]).sum();
pos[id] = sum / nodes[id].children.len() as f64;
}
}
let n_leaves = leaf_counter;
if n_leaves == 0 {
return;
}
let mut acc_dist = vec![0.0f64; n_nodes];
let mut stack = vec![root];
while let Some(id) = stack.pop() {
for &c in &nodes[id].children {
acc_dist[c] = acc_dist[id] + nodes[c].branch_length;
stack.push(c);
}
}
let mut internal_unique: Vec<f64> = (0..n_nodes)
.filter(|&id| !nodes[id].children.is_empty())
.map(|id| acc_dist[id])
.collect();
internal_unique.sort_by(|a, b| a.partial_cmp(b).unwrap());
internal_unique.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
let n_int = internal_unique.len();
if n_int == 0 {
return;
}
let row_dend_draw_w = (row_dend_w - 10.0).max(1.0);
let color = Color::from(branch_color);
let px = |id: usize| -> f64 {
let d_frac = if nodes[id].children.is_empty() {
1.0
} else {
let rank = internal_unique
.iter()
.position(|&u| (u - acc_dist[id]).abs() < 1e-10)
.unwrap_or(0);
rank as f64 / n_int as f64
};
hm_x - row_dend_draw_w + d_frac * row_dend_draw_w
};
let py = |id: usize| -> f64 { hm_y + (pos[id] + 0.5) * cell_h };
for &id in &post_order {
if nodes[id].children.is_empty() {
continue;
}
let children = &nodes[id].children;
let py_self = py(id);
let px_self = px(id);
let py_min = children
.iter()
.map(|&c| py(c))
.fold(f64::INFINITY, f64::min);
let py_max = children
.iter()
.map(|&c| py(c))
.fold(f64::NEG_INFINITY, f64::max);
scene.add(Primitive::Line {
x1: px_self,
y1: py_min,
x2: px_self,
y2: py_max,
stroke: color.clone(),
stroke_width: 1.0,
stroke_dasharray: None,
});
for &c in children {
let px_c = px(c);
let py_c = py(c);
scene.add(Primitive::Line {
x1: px_self,
y1: py_c,
x2: px_c,
y2: py_c,
stroke: color.clone(),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
let _ = py_self; }
}
#[allow(clippy::too_many_arguments)]
fn draw_col_dendrogram(
nodes: &[crate::plot::phylo::PhyloNode],
root: usize,
scene: &mut Scene,
hm_x: f64,
mt: f64,
col_dend_h: f64,
cell_w: f64,
branch_color: &str,
) {
use crate::plot::phylo::post_order_dfs;
let n_nodes = nodes.len();
if n_nodes == 0 {
return;
}
let post_order = post_order_dfs(root, nodes);
let mut pos = vec![0.0f64; n_nodes];
let mut leaf_counter = 0usize;
for &id in &post_order {
if nodes[id].children.is_empty() {
pos[id] = leaf_counter as f64;
leaf_counter += 1;
} else {
let sum: f64 = nodes[id].children.iter().map(|&c| pos[c]).sum();
pos[id] = sum / nodes[id].children.len() as f64;
}
}
let n_leaves = leaf_counter;
if n_leaves == 0 {
return;
}
let mut acc_dist = vec![0.0f64; n_nodes];
let mut stack = vec![root];
while let Some(id) = stack.pop() {
for &c in &nodes[id].children {
acc_dist[c] = acc_dist[id] + nodes[c].branch_length;
stack.push(c);
}
}
let mut internal_unique: Vec<f64> = (0..n_nodes)
.filter(|&id| !nodes[id].children.is_empty())
.map(|id| acc_dist[id])
.collect();
internal_unique.sort_by(|a, b| a.partial_cmp(b).unwrap());
internal_unique.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
let n_int = internal_unique.len();
if n_int == 0 {
return;
}
let col_dend_draw_h = (col_dend_h - 5.0).max(1.0);
let color = Color::from(branch_color);
let py = |id: usize| -> f64 {
let d_frac = if nodes[id].children.is_empty() {
1.0
} else {
let rank = internal_unique
.iter()
.position(|&u| (u - acc_dist[id]).abs() < 1e-10)
.unwrap_or(0);
rank as f64 / n_int as f64
};
mt + 5.0 + d_frac * col_dend_draw_h
};
let px = |id: usize| -> f64 { hm_x + (pos[id] + 0.5) * cell_w };
for &id in &post_order {
if nodes[id].children.is_empty() {
continue;
}
let children = &nodes[id].children;
let py_self = py(id);
let px_min = children
.iter()
.map(|&c| px(c))
.fold(f64::INFINITY, f64::min);
let px_max = children
.iter()
.map(|&c| px(c))
.fold(f64::NEG_INFINITY, f64::max);
scene.add(Primitive::Line {
x1: px_min,
y1: py_self,
x2: px_max,
y2: py_self,
stroke: color.clone(),
stroke_width: 1.0,
stroke_dasharray: None,
});
for &c in children {
let px_c = px(c);
let py_c = py(c);
scene.add(Primitive::Line {
x1: px_c,
y1: py_self,
x2: px_c,
y2: py_c,
stroke: color.clone(),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
}
}
fn add_clustermap(cm: &Clustermap, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::phylo::post_order_dfs;
let n_rows = cm.data.len();
let n_cols = cm.data.first().map_or(0, |r| r.len());
if n_rows == 0 || n_cols == 0 {
return;
}
let default_row_labels: Vec<String> = (0..n_rows).map(|i| i.to_string()).collect();
let default_col_labels: Vec<String> = (0..n_cols).map(|i| i.to_string()).collect();
let row_label_strs: Vec<&str> = cm
.row_labels
.as_ref()
.unwrap_or(&default_row_labels)
.iter()
.map(|s| s.as_str())
.collect();
let col_label_strs: Vec<&str> = cm
.col_labels
.as_ref()
.unwrap_or(&default_col_labels)
.iter()
.map(|s| s.as_str())
.collect();
let row_tree: Option<(Vec<crate::plot::phylo::PhyloNode>, usize)> =
if let Some(ref tree) = cm.row_tree {
Some((tree.nodes.clone(), tree.root))
} else if cm.cluster_rows && n_rows >= 2 {
let dist = euclidean_dist_matrix(&cm.data);
Some(render_utils::upgma(&row_label_strs, &dist))
} else {
None
};
let col_tree: Option<(Vec<crate::plot::phylo::PhyloNode>, usize)> =
if let Some(ref tree) = cm.col_tree {
Some((tree.nodes.clone(), tree.root))
} else if cm.cluster_cols && n_cols >= 2 {
let transposed: Vec<Vec<f64>> = (0..n_cols)
.map(|j| (0..n_rows).map(|i| cm.data[i][j]).collect())
.collect();
let dist = euclidean_dist_matrix(&transposed);
Some(render_utils::upgma(&col_label_strs, &dist))
} else {
None
};
let row_perm: Vec<usize> = if let Some((ref nodes, root)) = row_tree {
let labels = cm.row_labels.as_ref().unwrap_or(&default_row_labels);
let label_to_idx: HashMap<&str, usize> = labels
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
post_order_dfs(root, nodes)
.into_iter()
.filter(|&id| nodes[id].children.is_empty())
.filter_map(|id| {
nodes[id]
.label
.as_deref()
.and_then(|l| label_to_idx.get(l).copied())
})
.collect()
} else {
(0..n_rows).collect()
};
let col_perm: Vec<usize> = if let Some((ref nodes, root)) = col_tree {
let labels = cm.col_labels.as_ref().unwrap_or(&default_col_labels);
let label_to_idx: HashMap<&str, usize> = labels
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
post_order_dfs(root, nodes)
.into_iter()
.filter(|&id| nodes[id].children.is_empty())
.filter_map(|id| {
nodes[id]
.label
.as_deref()
.and_then(|l| label_to_idx.get(l).copied())
})
.collect()
} else {
(0..n_cols).collect()
};
let data: Vec<Vec<f64>> = row_perm
.iter()
.map(|&r| col_perm.iter().map(|&c| cm.data[r][c]).collect())
.collect();
let row_labels_ord: Option<Vec<String>> = cm
.row_labels
.as_ref()
.map(|l| row_perm.iter().map(|&i| l[i].clone()).collect());
let col_labels_ord: Option<Vec<String>> = cm
.col_labels
.as_ref()
.map(|l| col_perm.iter().map(|&i| l[i].clone()).collect());
let data = apply_normalization(data, &cm.normalization);
let mut v_min = f64::INFINITY;
let mut v_max = f64::NEG_INFINITY;
for &v in data.iter().flatten() {
if v < v_min {
v_min = v;
}
if v > v_max {
v_max = v;
}
}
let norm_val =
|v: f64| -> f64 { ((v - v_min) / (v_max - v_min + f64::EPSILON)).clamp(0.0, 1.0) };
let ml = computed.margin_left;
let mt = computed.margin_top;
let pw = computed.plot_width();
let ph = computed.plot_height();
let row_dend_w = if row_tree.is_some() {
cm.row_dendrogram_width
} else {
0.0
};
let col_dend_h = if col_tree.is_some() {
cm.col_dendrogram_height
} else {
0.0
};
let row_annot_w: f64 = cm.row_annotations.iter().map(|t| t.width + 4.0).sum();
let col_annot_h: f64 = cm.col_annotations.iter().map(|t| t.width + 4.0).sum();
let row_label_w = row_labels_ord
.as_ref()
.map(|l| {
let max_chars = l.iter().map(|s| s.len()).max().unwrap_or(4);
(max_chars as f64 * 7.0 + 10.0).clamp(30.0, 200.0)
})
.unwrap_or(0.0);
let col_label_h = if col_labels_ord.is_some() { 80.0 } else { 0.0 };
let hm_x = ml + row_dend_w + row_annot_w;
let hm_y = mt + col_dend_h + col_annot_h;
let hm_w = (pw - row_dend_w - row_annot_w - row_label_w).max(10.0);
let hm_h = (ph - col_dend_h - col_annot_h - col_label_h).max(10.0);
let n_rows_ord = row_perm.len().max(1);
let n_cols_ord = col_perm.len().max(1);
let cell_w = hm_w / n_cols_ord as f64;
let cell_h = hm_h / n_rows_ord as f64;
if let Some((ref nodes, root)) = row_tree {
draw_row_dendrogram(
nodes,
root,
scene,
ml,
ml + row_dend_w,
hm_y,
row_dend_w,
cell_h,
&cm.branch_color,
);
}
if let Some((ref nodes, root)) = col_tree {
draw_col_dendrogram(
nodes,
root,
scene,
hm_x,
mt,
col_dend_h,
cell_w,
&cm.branch_color,
);
}
let mut x_cursor = ml + row_dend_w;
for track in &cm.row_annotations {
if let Some(ref label) = track.label {
scene.add(Primitive::Text {
x: x_cursor + track.width / 2.0,
y: hm_y + hm_h + 4.0,
content: label.clone(),
size: computed.body_size,
anchor: TextAnchor::End,
rotate: Some(-90.0),
bold: false,
color: None,
});
}
for (k, &orig_row) in row_perm.iter().enumerate() {
let color_str = track
.colors
.get(orig_row)
.map(|s| s.as_str())
.unwrap_or("#cccccc");
let y = hm_y + k as f64 * cell_h;
scene.add(Primitive::Rect {
x: x_cursor,
y,
width: track.width,
height: cell_h * 0.99,
fill: Color::from(color_str),
stroke: None,
stroke_width: None,
opacity: None,
});
}
x_cursor += track.width + 4.0;
}
let mut y_cursor = mt + col_dend_h;
for track in &cm.col_annotations {
if let Some(ref label) = track.label {
scene.add(Primitive::Text {
x: hm_x - 4.0,
y: y_cursor + track.width / 2.0,
content: label.clone(),
size: computed.body_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
for (k, &orig_col) in col_perm.iter().enumerate() {
let color_str = track
.colors
.get(orig_col)
.map(|s| s.as_str())
.unwrap_or("#cccccc");
let x = hm_x + k as f64 * cell_w;
scene.add(Primitive::Rect {
x,
y: y_cursor,
width: cell_w * 0.99,
height: track.width,
fill: Color::from(color_str),
stroke: None,
stroke_width: None,
opacity: None,
});
}
y_cursor += track.width + 4.0;
}
let n_cells = data.iter().map(|r| r.len()).sum::<usize>();
let use_batch = !cm.show_tooltips;
if use_batch {
let mut xs = Vec::with_capacity(n_cells);
let mut ys = Vec::with_capacity(n_cells);
let mut ws = Vec::with_capacity(n_cells);
let mut hs = Vec::with_capacity(n_cells);
let mut fills = Vec::with_capacity(n_cells);
for (row_k, row) in data.iter().enumerate() {
for (col_k, &value) in row.iter().enumerate() {
xs.push(hm_x + col_k as f64 * cell_w);
ys.push(hm_y + row_k as f64 * cell_h);
ws.push(cell_w * 0.99);
hs.push(cell_h * 0.99);
fills.push(Color::from(cm.color_map.map(norm_val(value))));
}
}
scene.add(Primitive::RectBatch {
x: xs,
y: ys,
w: ws,
h: hs,
fills,
});
} else {
for (row_k, row) in data.iter().enumerate() {
for (col_k, &value) in row.iter().enumerate() {
let x = hm_x + col_k as f64 * cell_w;
let y = hm_y + row_k as f64 * cell_h;
let orig_row = row_perm.get(row_k).copied().unwrap_or(row_k);
let orig_col = col_perm.get(col_k).copied().unwrap_or(col_k);
let row_lbl = row_labels_ord
.as_ref()
.and_then(|l| l.get(row_k))
.map(|s| s.as_str())
.unwrap_or("");
let col_lbl = col_labels_ord
.as_ref()
.and_then(|l| l.get(col_k))
.map(|s| s.as_str())
.unwrap_or("");
let tip = if cm.show_tooltips {
Some(format!(
"{}, {}: {:.2}",
row_lbl, col_lbl, cm.data[orig_row][orig_col]
))
} else {
None
};
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Rect {
x,
y,
width: cell_w * 0.99,
height: cell_h * 0.99,
fill: Color::from(cm.color_map.map(norm_val(value))),
stroke: None,
stroke_width: None,
opacity: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
}
}
if cm.show_values {
for (row_k, row) in data.iter().enumerate() {
for (col_k, &value) in row.iter().enumerate() {
scene.add(Primitive::Text {
x: hm_x + (col_k as f64 + 0.5) * cell_w,
y: hm_y + (row_k as f64 + 0.5) * cell_h + computed.body_size as f64 * 0.35,
content: format!("{:.2}", value),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
let ts = computed.body_size;
if let Some(ref labels) = row_labels_ord {
for (k, label) in labels.iter().enumerate() {
scene.add(Primitive::Text {
x: hm_x + hm_w + 6.0,
y: hm_y + (k as f64 + 0.5) * cell_h + ts as f64 * 0.35,
content: label.clone(),
size: ts,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
if let Some(ref labels) = col_labels_ord {
for (k, label) in labels.iter().enumerate() {
scene.add(Primitive::Text {
x: hm_x + (k as f64 + 0.5) * cell_w,
y: hm_y + hm_h + 6.0,
content: label.clone(),
size: ts,
anchor: TextAnchor::End,
rotate: Some(-45.0),
bold: false,
color: None,
});
}
}
}
fn add_waterfall(waterfall: &WaterfallPlot, scene: &mut Scene, computed: &ComputedLayout) {
let mut running = 0.0_f64;
let mut prev_connection_y: Option<f64> = None;
let half = waterfall.bar_width / 2.0;
for (i, bar) in waterfall.bars.iter().enumerate() {
let x_center = (i + 1) as f64;
let x0 = computed.map_x(x_center - half);
let x1 = computed.map_x(x_center + half);
let (base, top, color) = match bar.kind {
WaterfallKind::Delta => {
let base = running;
running += bar.value;
let color = if bar.value >= 0.0 {
waterfall.color_positive.clone()
} else {
waterfall.color_negative.clone()
};
(base, running, color)
}
WaterfallKind::Total => (0.0, running, waterfall.color_total.clone()),
WaterfallKind::Difference { from, to } => {
let color = if to >= from {
waterfall.color_positive.clone()
} else {
waterfall.color_negative.clone()
};
(from, to, color)
}
};
if waterfall.show_connectors {
if let Some(py) = prev_connection_y {
let prev_x_right = computed.map_x(i as f64 + half);
scene.add(Primitive::Line {
x1: prev_x_right,
y1: py,
x2: x0,
y2: py,
stroke: "gray".into(),
stroke_width: 1.0,
stroke_dasharray: Some("4,3".into()),
});
}
}
let y_screen_lo = computed.map_y(base.min(top));
let y_screen_hi = computed.map_y(base.max(top));
let bar_height = (y_screen_lo - y_screen_hi).abs();
if bar_height > 0.0 {
let tip = tooltip(
waterfall.show_tooltips,
&waterfall.tooltip_labels,
i,
|| format!("{}: {:.2}", bar.label, top - base),
);
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Rect {
x: x0,
y: y_screen_hi,
width: (x1 - x0).abs(),
height: bar_height,
fill: color.into(),
stroke: None,
stroke_width: None,
opacity: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
if waterfall.show_values {
let (display, label_y) = match bar.kind {
WaterfallKind::Delta => {
let s = if bar.value >= 0.0 {
format!("+{:.2}", bar.value)
} else {
format!("{:.2}", bar.value)
};
let ly = if bar.value >= 0.0 {
y_screen_hi - 5.0
} else {
y_screen_lo + 15.0
};
(s, ly)
}
WaterfallKind::Total => {
let s = format!("{:.2}", running);
let ly = if running >= 0.0 {
y_screen_hi - 5.0
} else {
y_screen_lo + 15.0
};
(s, ly)
}
WaterfallKind::Difference { from, to } => {
let diff = to - from;
let s = if diff >= 0.0 {
format!("+{:.2}", diff)
} else {
format!("{:.2}", diff)
};
let ly = if diff >= 0.0 {
y_screen_hi - 5.0
} else {
y_screen_lo + 15.0
};
(s, ly)
}
};
scene.add(Primitive::Text {
x: (x0 + x1) / 2.0,
y: label_y,
content: display,
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
prev_connection_y = Some(match bar.kind {
WaterfallKind::Difference { to, .. } => computed.map_y(to),
_ => computed.map_y(running),
});
}
}
fn render_legend_entry(
entry: &LegendEntry,
scene: &mut Scene,
legend_x: f64,
cur_y: f64,
computed: &ComputedLayout,
) {
let swatch_cy = cur_y + computed.legend_swatch_size / 2.0 - 1.0;
let text_baseline = swatch_cy + computed.body_size as f64 * 0.35;
scene.add(Primitive::Text {
x: legend_x + computed.legend_text_x,
y: text_baseline,
content: entry.label.clone(),
anchor: TextAnchor::Start,
size: computed.body_size,
rotate: None,
bold: false,
color: None,
});
match entry.shape {
LegendShape::Rect => scene.add(Primitive::Rect {
x: legend_x + computed.legend_swatch_x,
y: cur_y - computed.axis_stroke_width,
width: computed.legend_swatch_size,
height: computed.legend_swatch_size,
fill: Color::from(&entry.color),
stroke: None,
stroke_width: None,
opacity: None,
}),
LegendShape::Line => scene.add(Primitive::Line {
x1: legend_x + computed.legend_swatch_x,
y1: swatch_cy,
x2: legend_x + computed.legend_swatch_x + computed.legend_swatch_size,
y2: swatch_cy,
stroke: Color::from(&entry.color),
stroke_width: computed.axis_stroke_width * 2.0,
stroke_dasharray: entry.dasharray.clone(),
}),
LegendShape::Circle => scene.add(Primitive::Circle {
cx: legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
cy: swatch_cy,
r: computed.legend_swatch_r,
fill: Color::from(&entry.color),
fill_opacity: None,
stroke: None,
stroke_width: None,
}),
LegendShape::Marker(marker) => {
draw_marker(
scene,
marker,
legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
swatch_cy,
computed.legend_swatch_r,
&entry.color,
None,
None,
None,
);
}
LegendShape::CircleSize(r) => {
let draw_r = r.min(computed.legend_swatch_half);
scene.add(Primitive::Circle {
cx: legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
cy: swatch_cy,
r: draw_r,
fill: Color::from(&entry.color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
}
fn add_stats_box(
layout: &Layout,
scene: &mut Scene,
computed: &ComputedLayout,
) -> Option<(f64, f64, f64)> {
if layout.stats_entries.is_empty() && layout.stats_title.is_none() {
return None;
}
let theme = &computed.theme;
let line_height = computed.legend_line_height;
let padding = computed.legend_padding;
let body_size = computed.body_size;
let s = computed.axis_stroke_width;
let title_rows = if layout.stats_title.is_some() { 1 } else { 0 };
let n_rows = title_rows + layout.stats_entries.len();
let box_height = n_rows as f64 * line_height + padding * 2.0;
let max_chars = layout
.stats_entries
.iter()
.map(|e| e.len())
.chain(layout.stats_title.iter().map(|t| t.len()))
.max()
.unwrap_or(8) as f64;
let box_width = (max_chars * body_size as f64 * 0.6 + 20.0 * s).max(80.0 * s);
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let plot_top = computed.margin_top;
let plot_bottom = computed.height - computed.margin_bottom;
let plot_cx = (plot_left + plot_right) / 2.0;
let right_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
let left_x = padding; let inset = computed.legend_inset;
let (box_x, box_y) = match computed.stats_position {
LegendPosition::InsideTopRight => (
plot_right - inset - box_width + 5.0,
plot_top + inset + padding,
),
LegendPosition::InsideTopLeft => (
plot_left + inset + computed.tick_label_margin + 5.0,
plot_top + inset + padding,
),
LegendPosition::InsideBottomRight => (
plot_right - inset - box_width + 5.0,
plot_bottom - inset - box_height + padding,
),
LegendPosition::InsideBottomLeft => (
plot_left + inset + computed.tick_label_margin + 5.0,
plot_bottom - inset - box_height + padding,
),
LegendPosition::InsideTopCenter => {
(plot_cx - box_width / 2.0 + 5.0, plot_top + inset + padding)
}
LegendPosition::InsideBottomCenter => (
plot_cx - box_width / 2.0 + 5.0,
plot_bottom - inset - box_height + padding,
),
LegendPosition::OutsideRightTop => (right_x, plot_top),
LegendPosition::OutsideRightMiddle => {
(right_x, (plot_top + plot_bottom) / 2.0 - box_height / 2.0)
}
LegendPosition::OutsideRightBottom => (right_x, plot_bottom - box_height),
LegendPosition::OutsideLeftTop => (left_x, plot_top),
LegendPosition::OutsideLeftMiddle => {
(left_x, (plot_top + plot_bottom) / 2.0 - box_height / 2.0)
}
LegendPosition::OutsideLeftBottom => (left_x, plot_bottom - box_height),
LegendPosition::OutsideTopLeft => (plot_left, padding + 10.0),
LegendPosition::OutsideTopCenter => (plot_cx - box_width / 2.0, padding + 10.0),
LegendPosition::OutsideTopRight => (plot_right - box_width, padding + 10.0),
LegendPosition::OutsideBottomLeft => (
plot_left,
computed.height - computed.margin_bottom + padding + 10.0,
),
LegendPosition::OutsideBottomCenter => (
plot_cx - box_width / 2.0,
computed.height - computed.margin_bottom + padding + 10.0,
),
LegendPosition::OutsideBottomRight => (
plot_right - box_width,
computed.height - computed.margin_bottom + padding + 10.0,
),
LegendPosition::OutsideBottomColumns => (
plot_left,
computed.height - computed.margin_bottom + padding + 10.0,
),
LegendPosition::Custom(x, y) => (x, y),
LegendPosition::DataCoords(x, y) => (computed.map_x(x), computed.map_y(y)),
};
if layout.stats_box {
scene.add(Primitive::Rect {
x: box_x - padding + 5.0,
y: box_y - padding,
width: box_width,
height: box_height,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: box_x - padding + 5.0,
y: box_y - padding,
width: box_width,
height: box_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(computed.axis_stroke_width),
opacity: None,
});
}
let mut cur_y = box_y;
if let Some(ref title) = layout.stats_title {
scene.add(Primitive::Text {
x: box_x + box_width / 2.0,
y: cur_y + 5.0,
content: title.clone(),
anchor: TextAnchor::Middle,
size: body_size,
rotate: None,
bold: true,
color: None,
});
cur_y += line_height;
}
for entry in &layout.stats_entries {
scene.add(Primitive::Text {
x: box_x + 5.0 * s,
y: cur_y + body_size as f64 * 0.8,
content: entry.clone(),
anchor: TextAnchor::Start,
size: body_size,
rotate: None,
bold: false,
color: None,
});
cur_y += line_height;
}
Some((box_x, box_y, box_height))
}
fn add_legend_at(legend: &Legend, scene: &mut Scene, computed: &ComputedLayout, y_start: f64) {
let theme = &computed.theme;
let legend_width = computed.legend_width;
let legend_padding = computed.legend_padding;
let line_height = computed.legend_line_height;
let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
let legend_y = y_start;
let avail_height = (computed.height - legend_y - legend_padding * 2.0).max(line_height);
let max_entries = ((avail_height / line_height).floor() as usize).max(10);
let n_total = legend.entries.len();
let overflow = if n_total > max_entries {
n_total - max_entries.saturating_sub(1)
} else {
0
};
let entries_to_show = if overflow > 0 {
max_entries.saturating_sub(1)
} else {
n_total
};
let legend_height = (entries_to_show + if overflow > 0 { 1 } else { 0 }) as f64 * line_height
+ legend_padding * 2.0;
let overflow_label = if overflow > 0 {
format!("… (+{overflow} more)")
} else {
String::new()
};
let box_width = if overflow > 0 {
let min_w = overflow_label.chars().count() as f64 * 7.5 + 18.0 + legend_padding;
legend_width.max(min_w)
} else {
legend_width
};
if legend.show_box {
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: legend_y - legend_padding,
width: box_width,
height: legend_height,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: legend_y - legend_padding,
width: box_width,
height: legend_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(1.0),
opacity: None,
});
}
let mut cur_y = legend_y;
for entry in legend.entries.iter().take(entries_to_show) {
let swatch_x = legend_x;
let swatch_y = cur_y;
match entry.shape {
LegendShape::Circle | LegendShape::CircleSize(_) => {
let r = if let LegendShape::CircleSize(r) = entry.shape {
r
} else {
5.0
};
scene.add(Primitive::Circle {
cx: swatch_x + 5.0,
cy: swatch_y + line_height / 2.0 - 2.0,
r,
fill: entry.color.clone().into(),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
_ => {
scene.add(Primitive::Rect {
x: swatch_x,
y: swatch_y,
width: 12.0,
height: 12.0,
fill: entry.color.clone().into(),
stroke: None,
stroke_width: None,
opacity: None,
});
}
}
scene.add(Primitive::Text {
x: swatch_x + 18.0,
y: swatch_y + computed.body_size as f64 * 0.8,
content: entry.label.clone(),
size: computed.body_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
cur_y += line_height;
}
if overflow > 0 {
scene.add(Primitive::Text {
x: legend_x + 18.0,
y: cur_y + computed.body_size as f64 * 0.8,
content: format!("… (+{overflow} more)"),
size: computed.body_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
fn add_legend_with_offset(
legend: &Legend,
scene: &mut Scene,
computed: &ComputedLayout,
y_offset: f64,
) {
if matches!(
computed.legend_position,
LegendPosition::OutsideBottomColumns
) {
let n_cols = computed.legend_col_count.max(1);
let line_height = computed.legend_line_height;
let legend_padding = computed.legend_padding;
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let n_entries = legend.entries.len().max(1);
let n_rows = n_entries.div_ceil(n_cols);
let legend_y = computed.height - computed.legend_bottom_extra + 5.0;
let avail_w = plot_right - plot_left;
let col_w = avail_w / n_cols as f64;
let theme = &computed.theme;
if legend.show_box {
let box_h = n_rows as f64 * line_height + legend_padding * 2.0;
scene.add(Primitive::Rect {
x: plot_left - legend_padding + 5.0,
y: legend_y - legend_padding,
width: avail_w + legend_padding,
height: box_h,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: plot_left - legend_padding + 5.0,
y: legend_y - legend_padding,
width: avail_w + legend_padding,
height: box_h,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(computed.axis_stroke_width),
opacity: None,
});
}
for (i, entry) in legend.entries.iter().enumerate() {
let col = i % n_cols;
let row = i / n_cols;
let ex = plot_left + col as f64 * col_w;
let ey = legend_y + row as f64 * line_height;
render_legend_entry(entry, scene, ex, ey, computed);
}
return;
}
let theme = &computed.theme;
let legend_width = computed.legend_width;
let legend_padding = computed.legend_padding;
let line_height = computed.legend_line_height;
let n_groups = legend.groups.as_ref().map_or(0, |g| g.len());
let group_gap = line_height * 0.5;
let wrap_lines = |text: &str| -> usize {
if let Some(max_chars) = computed.legend_wrap {
render_utils::wrap_text(text, max_chars).len()
} else {
1
}
};
let entry_rows = if let Some(ref groups) = legend.groups {
groups
.iter()
.map(|g| {
let title_lines = wrap_lines(&g.title);
let entry_lines: usize = g.entries.iter().map(|e| wrap_lines(&e.label)).sum();
title_lines + entry_lines
})
.sum::<usize>()
} else {
legend
.entries
.iter()
.map(|e| wrap_lines(&e.label))
.sum::<usize>()
};
let title_rows = if let Some(ref t) = legend.title {
wrap_lines(t)
} else {
0
};
let inter_group_extra = if n_groups > 1 {
(n_groups - 1) as f64 * group_gap
} else {
0.0
};
let computed_height =
(entry_rows + title_rows) as f64 * line_height + inter_group_extra + legend_padding * 2.0;
let legend_height = computed.legend_height_override.unwrap_or(computed_height);
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let plot_top = computed.margin_top;
let plot_bottom = computed.height - computed.margin_bottom;
let plot_cx = (plot_left + plot_right) / 2.0;
let right_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
let left_x = legend_padding;
let inset = computed.legend_inset;
let (legend_x, legend_y) = match computed.legend_position {
LegendPosition::InsideTopRight => (
plot_right - inset - legend_width + 5.0,
plot_top + inset + legend_padding,
),
LegendPosition::InsideTopLeft => (
plot_left + inset + computed.tick_label_margin + 5.0,
plot_top + inset + legend_padding,
),
LegendPosition::InsideBottomRight => (
plot_right - inset - legend_width + 5.0,
plot_bottom - inset - legend_height + legend_padding,
),
LegendPosition::InsideBottomLeft => (
plot_left + inset + computed.tick_label_margin + 5.0,
plot_bottom - inset - legend_height + legend_padding,
),
LegendPosition::InsideTopCenter => (
plot_cx - legend_width / 2.0 + 5.0,
plot_top + inset + legend_padding,
),
LegendPosition::InsideBottomCenter => (
plot_cx - legend_width / 2.0 + 5.0,
plot_bottom - inset - legend_height + legend_padding,
),
LegendPosition::OutsideRightTop => (right_x, plot_top),
LegendPosition::OutsideRightMiddle => (
right_x,
(plot_top + plot_bottom) / 2.0 - legend_height / 2.0,
),
LegendPosition::OutsideRightBottom => (right_x, plot_bottom - legend_height),
LegendPosition::OutsideLeftTop => (left_x, plot_top),
LegendPosition::OutsideLeftMiddle => {
(left_x, (plot_top + plot_bottom) / 2.0 - legend_height / 2.0)
}
LegendPosition::OutsideLeftBottom => (left_x, plot_bottom - legend_height),
LegendPosition::OutsideTopLeft => (plot_left, legend_padding + 10.0),
LegendPosition::OutsideTopCenter => (plot_cx - legend_width / 2.0, legend_padding + 10.0),
LegendPosition::OutsideTopRight => (plot_right - legend_width, legend_padding + 10.0),
LegendPosition::OutsideBottomLeft => {
(plot_left, computed.height - legend_height + legend_padding)
}
LegendPosition::OutsideBottomCenter => (
plot_cx - legend_width / 2.0,
computed.height - legend_height + legend_padding,
),
LegendPosition::OutsideBottomRight => (
plot_right - legend_width,
computed.height - legend_height + legend_padding,
),
LegendPosition::OutsideBottomColumns => {
(plot_left, computed.height - legend_height + legend_padding)
}
LegendPosition::Custom(x, y) => (x, y),
LegendPosition::DataCoords(x, y) => (computed.map_x(x), computed.map_y(y)),
};
let legend_y = legend_y + y_offset;
let avail_height_entries = (computed.height - legend_y - legend_padding * 2.0).max(line_height);
let max_entries_display = ((avail_height_entries / line_height).floor() as usize).max(10);
let flat_overflow = if legend.groups.is_none() {
let n = legend.entries.len();
if n > max_entries_display {
n - max_entries_display.saturating_sub(1)
} else {
0
}
} else {
0
};
let box_width = if flat_overflow > 0 {
let overflow_text = format!("… (+{flat_overflow} more)");
let min_w =
overflow_text.chars().count() as f64 * 7.5 + computed.legend_text_x + legend_padding;
legend_width.max(min_w)
} else {
legend_width
};
let legend_height = if flat_overflow > 0 && computed.legend_height_override.is_none() {
(title_rows + max_entries_display) as f64 * line_height + legend_padding * 2.0
} else {
legend_height
};
if legend.show_box {
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: legend_y - legend_padding,
width: box_width,
height: legend_height,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: legend_y - legend_padding,
width: box_width,
height: legend_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(computed.axis_stroke_width),
opacity: None,
});
}
let mut cur_y = legend_y;
let wrap_max = computed.legend_wrap;
let text_baseline_offset =
computed.legend_swatch_size / 2.0 - 1.0 + computed.body_size as f64 * 0.35;
if let Some(ref title) = legend.title {
let lines = render_utils::wrap_or_single(title, wrap_max);
for line in &lines {
scene.add(Primitive::Text {
x: legend_x + legend_width / 2.0,
y: cur_y + 5.0,
content: line.clone(),
anchor: TextAnchor::Middle,
size: computed.body_size,
rotate: None,
bold: true,
color: None,
});
cur_y += line_height;
}
}
let render_entry = |entry: &LegendEntry, scene: &mut Scene, cur_y: &mut f64| {
if computed.interactive {
let grp_attr = format!(
r#"class="legend-entry" data-group="{lbl}""#,
lbl = entry.label
);
scene.add(Primitive::GroupStart {
transform: None,
title: None,
extra_attrs: Some(grp_attr),
});
}
let lines = render_utils::wrap_or_single(&entry.label, wrap_max);
let mut first = entry.clone();
first.label = lines[0].clone();
render_legend_entry(&first, scene, legend_x, *cur_y, computed);
*cur_y += line_height;
for line in &lines[1..] {
scene.add(Primitive::Text {
x: legend_x + computed.legend_text_x,
y: *cur_y + text_baseline_offset,
content: line.clone(),
anchor: TextAnchor::Start,
size: computed.body_size,
rotate: None,
bold: false,
color: None,
});
*cur_y += line_height;
}
if computed.interactive {
scene.add(Primitive::GroupEnd);
}
};
if let Some(ref groups) = legend.groups {
for (i, group) in groups.iter().enumerate() {
if i > 0 {
cur_y += group_gap;
}
let title_lines = render_utils::wrap_or_single(&group.title, wrap_max);
for line in &title_lines {
scene.add(Primitive::Text {
x: legend_x + 5.0,
y: cur_y + 5.0,
content: line.clone(),
anchor: TextAnchor::Start,
size: computed.body_size,
rotate: None,
bold: true,
color: None,
});
cur_y += line_height;
}
for entry in &group.entries {
render_entry(entry, scene, &mut cur_y);
}
}
} else {
let overflow = flat_overflow;
let entries_to_show = if overflow > 0 {
max_entries_display.saturating_sub(1)
} else {
legend.entries.len()
};
for entry in legend.entries.iter().take(entries_to_show) {
render_entry(entry, scene, &mut cur_y);
}
if overflow > 0 {
scene.add(Primitive::Text {
x: legend_x + computed.legend_text_x,
y: cur_y + text_baseline_offset,
content: format!("… (+{overflow} more)"),
anchor: TextAnchor::Start,
size: computed.body_size,
rotate: None,
bold: false,
color: None,
});
}
}
}
fn add_colorbar_at(
info: &ColorBarInfo,
scene: &mut Scene,
computed: &ComputedLayout,
bar_x: f64,
bar_y: f64,
bar_height: f64,
) {
let theme = &computed.theme;
let bar_width = computed.colorbar_bar_width;
let num_slices = 50;
let slice_height = bar_height / num_slices as f64;
for i in 0..num_slices {
let t = 1.0 - (i as f64 / (num_slices - 1) as f64); let value = info.min_value + t * (info.max_value - info.min_value);
let color = (info.map_fn)(value);
let y = bar_y + i as f64 * slice_height;
scene.add(Primitive::Rect {
x: bar_x,
y,
width: bar_width,
height: slice_height + 0.5, fill: color.into(),
stroke: None,
stroke_width: None,
opacity: None,
});
}
scene.add(Primitive::Rect {
x: bar_x,
y: bar_y,
width: bar_width,
height: bar_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.colorbar_border)),
stroke_width: Some(computed.axis_stroke_width),
opacity: None,
});
let range = info.max_value - info.min_value;
let auto_ticks: Vec<(f64, String)>;
let tick_entries: &[(f64, String)] = if let Some(ref tl) = info.tick_labels {
tl.as_slice()
} else {
let raw = render_utils::generate_ticks(info.min_value, info.max_value, 5);
auto_ticks = raw
.into_iter()
.filter(|t| *t >= info.min_value && *t <= info.max_value)
.map(|t| (t, computed.colorbar_tick_format.format(t)))
.collect();
auto_ticks.as_slice()
};
for (pos, label) in tick_entries {
if *pos < info.min_value || *pos > info.max_value {
continue;
}
let frac = (pos - info.min_value) / range;
let y = bar_y + bar_height - frac * bar_height;
scene.add(Primitive::Line {
x1: bar_x + bar_width,
y1: y,
x2: bar_x + bar_width + computed.tick_mark_major * 0.8,
y2: y,
stroke: Color::from(&theme.colorbar_border),
stroke_width: computed.axis_stroke_width,
stroke_dasharray: None,
});
scene.add(Primitive::Text {
x: bar_x + bar_width + computed.tick_mark_major,
y: y + 4.0,
content: label.clone(),
size: computed.tick_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
if let Some(ref label) = info.label {
let label_x = bar_x - computed.tick_size as f64 * 0.5 - 4.0;
let label_y = bar_y + bar_height / 2.0;
scene.add(Primitive::Text {
x: label_x,
y: label_y,
content: label.clone(),
size: computed.tick_size,
anchor: TextAnchor::Middle,
rotate: Some(-90.0),
bold: false,
color: None,
});
}
}
fn add_colorbar(info: &ColorBarInfo, scene: &mut Scene, computed: &ComputedLayout) {
let bar_x = computed.width - computed.colorbar_x_inset;
let bar_y = computed.margin_top + computed.plot_height() * 0.1;
let bar_height = computed.plot_height() * 0.8;
add_colorbar_at(info, scene, computed, bar_x, bar_y, bar_height);
}
fn add_volcano(vp: &VolcanoPlot, scene: &mut Scene, computed: &ComputedLayout) {
let floor = vp.floor();
let threshold_color = "#888888";
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let plot_top = computed.margin_top;
let plot_bottom = computed.height - computed.margin_bottom;
let y_sig = -vp.p_cutoff.log10();
if y_sig >= computed.y_range.0 && y_sig <= computed.y_range.1 {
let sy = computed.map_y(y_sig);
if computed.interactive {
scene.add(Primitive::GroupStart {
transform: None,
title: None,
extra_attrs: Some(r#"class="kuva-threshold""#.to_string()),
});
}
scene.add(Primitive::Line {
x1: plot_left,
y1: sy,
x2: plot_right,
y2: sy,
stroke: threshold_color.into(),
stroke_width: 1.0,
stroke_dasharray: Some("4 4".into()),
});
if computed.interactive {
scene.add(Primitive::GroupEnd);
}
}
for &fc_val in &[-vp.fc_cutoff, vp.fc_cutoff] {
if fc_val >= computed.x_range.0 && fc_val <= computed.x_range.1 {
let sx = computed.map_x(fc_val);
if computed.interactive {
scene.add(Primitive::GroupStart {
transform: None,
title: None,
extra_attrs: Some(r#"class="kuva-threshold""#.to_string()),
});
}
scene.add(Primitive::Line {
x1: sx,
y1: plot_top,
x2: sx,
y2: plot_bottom,
stroke: threshold_color.into(),
stroke_width: 1.0,
stroke_dasharray: Some("4 4".into()),
});
if computed.interactive {
scene.add(Primitive::GroupEnd);
}
}
}
for pass in 0..3u8 {
for (pi, p) in vp.points.iter().enumerate() {
let is_up = p.log2fc >= vp.fc_cutoff && p.pvalue <= vp.p_cutoff;
let is_down = p.log2fc <= -vp.fc_cutoff && p.pvalue <= vp.p_cutoff;
let color = match (pass, is_up, is_down) {
(0, false, false) => &vp.color_ns,
(1, false, true) => &vp.color_down,
(2, true, false) => &vp.color_up,
_ => continue,
};
let y_val = -(p.pvalue.max(floor)).log10();
let cx = computed.map_x(p.log2fc);
let cy = computed.map_y(y_val);
let tip = tooltip(
vp.show_tooltips || computed.interactive,
&vp.tooltip_labels,
pi,
|| format!("{}\nlog2FC={:.2}\np={:.2e}", p.name, p.log2fc, p.pvalue),
);
let volcano_extra = if computed.interactive {
let group = match pass {
0 => "NS",
1 => "Down",
_ => "Up",
};
Some(format!(
"class=\"tt\" data-logfc=\"{lfc}\" data-pvalue=\"{pv}\" data-group=\"{group}\"",
lfc = p.log2fc,
pv = p.pvalue
))
} else {
None
};
if tip.is_some() || volcano_extra.is_some() {
scene.add(Primitive::GroupStart {
transform: None,
title: tip.clone(),
extra_attrs: volcano_extra,
});
}
scene.add(Primitive::Circle {
cx,
cy,
r: vp.point_size,
fill: Color::from(color.as_str()),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
if tip.is_some() || computed.interactive {
scene.add(Primitive::GroupEnd);
}
}
}
if vp.label_top == 0 {
return;
}
let mut sig_points: Vec<(f64, f64, &str)> = vp
.points
.iter()
.filter(|p| p.pvalue <= vp.p_cutoff)
.map(|p| {
let y_val = -(p.pvalue.max(floor)).log10();
(
computed.map_x(p.log2fc),
computed.map_y(y_val),
p.name.as_str(),
)
})
.collect();
sig_points.sort_by(|a, b| a.1.total_cmp(&b.1));
sig_points.truncate(vp.label_top);
match vp.label_style {
LabelStyle::Exact => {
for (cx, cy, name) in &sig_points {
scene.add(Primitive::Text {
x: *cx,
y: cy - vp.point_size - 2.0,
content: name.to_string(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
LabelStyle::Nudge => {
let mut labels: Vec<(f64, f64, String)> = sig_points
.iter()
.map(|(cx, cy, name)| (*cx, cy - vp.point_size - 2.0, name.to_string()))
.collect();
labels.sort_by(|a, b| a.0.total_cmp(&b.0));
let min_gap = computed.body_size as f64 + 2.0;
for j in 1..labels.len() {
let prev_y = labels[j - 1].1;
let curr_y = labels[j].1;
if (prev_y - curr_y).abs() < min_gap {
labels[j].1 = prev_y - min_gap;
}
}
for (cx, label_y, name) in &labels {
scene.add(Primitive::Text {
x: *cx,
y: *label_y,
content: name.clone(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
LabelStyle::Arrow { offset_x, offset_y } => {
for (cx, cy, name) in &sig_points {
let text_x = cx + offset_x;
let text_y = cy - offset_y;
let dx = cx - text_x;
let dy = cy - text_y;
let len = (dx * dx + dy * dy).sqrt();
if len > vp.point_size + 3.0 {
let scale = (len - vp.point_size - 3.0) / len;
let end_x = text_x + dx * scale;
let end_y = text_y + dy * scale;
scene.add(Primitive::Line {
x1: text_x,
y1: text_y,
x2: end_x,
y2: end_y,
stroke: "#666666".into(),
stroke_width: 0.8,
stroke_dasharray: None,
});
}
let anchor = if offset_x >= 0.0 {
TextAnchor::Start
} else {
TextAnchor::End
};
scene.add(Primitive::Text {
x: text_x,
y: text_y,
content: name.to_string(),
size: computed.body_size,
anchor,
rotate: None,
bold: false,
color: None,
});
}
}
}
}
fn add_manhattan(mp: &ManhattanPlot, scene: &mut Scene, computed: &ComputedLayout) {
let floor = mp.floor();
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let plot_top = computed.margin_top;
let plot_bottom = computed.height - computed.margin_bottom;
let gw_y = mp.genome_wide;
if gw_y >= computed.y_range.0 && gw_y <= computed.y_range.1 {
let sy = computed.map_y(gw_y);
scene.add(Primitive::Line {
x1: plot_left,
y1: sy,
x2: plot_right,
y2: sy,
stroke: "#cc3333".into(),
stroke_width: 1.0,
stroke_dasharray: Some("4 4".into()),
});
}
let sg_y = mp.suggestive;
if sg_y >= computed.y_range.0 && sg_y <= computed.y_range.1 {
let sy = computed.map_y(sg_y);
scene.add(Primitive::Line {
x1: plot_left,
y1: sy,
x2: plot_right,
y2: sy,
stroke: "#888888".into(),
stroke_width: 1.0,
stroke_dasharray: Some("4 4".into()),
});
}
for span in mp.spans.iter().skip(1) {
let sx = computed.map_x(span.x_start);
if sx >= plot_left && sx <= plot_right {
scene.add(Primitive::Line {
x1: sx,
y1: plot_top,
x2: sx,
y2: plot_bottom,
stroke: Color::from(&computed.theme.grid_color),
stroke_width: 0.5,
stroke_dasharray: None,
});
}
}
let mut by_chr: HashMap<&str, Vec<usize>> = HashMap::new();
for (idx, p) in mp.points.iter().enumerate() {
by_chr.entry(p.chromosome.as_str()).or_default().push(idx);
}
for (span_idx, span) in mp.spans.iter().enumerate() {
let color = if let Some(ref pal) = mp.palette {
pal[span_idx].to_string()
} else if span_idx % 2 == 0 {
mp.color_a.clone()
} else {
mp.color_b.clone()
};
let band_left = computed.map_x(span.x_start).max(plot_left);
let band_right = computed.map_x(span.x_end).min(plot_right);
for &idx in by_chr
.get(span.name.as_str())
.map(|v| v.as_slice())
.unwrap_or(&[])
{
let p = &mp.points[idx];
let y_val = -(p.pvalue.max(floor)).log10();
let cx = computed.map_x(p.x).clamp(band_left, band_right);
let cy = computed.map_y(y_val);
let tip = tooltip(mp.show_tooltips, &mp.tooltip_labels, idx, || {
let name = p.label.as_deref().unwrap_or("");
format!("{}\n{}:{:.0}\np={:.2e}", name, p.chromosome, p.x, p.pvalue)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Circle {
cx,
cy,
r: mp.point_size,
fill: Color::from(&color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
}
if mp.label_top == 0 {
return;
}
let mut sig_points: Vec<(f64, f64, String)> = mp
.points
.iter()
.map(|p| {
let y_val = -(p.pvalue.max(floor)).log10();
let label = p.label.clone().unwrap_or_else(|| p.chromosome.clone());
(computed.map_x(p.x), computed.map_y(y_val), label)
})
.collect();
sig_points.sort_by(|a, b| a.1.total_cmp(&b.1));
sig_points.truncate(mp.label_top);
match mp.label_style {
LabelStyle::Exact => {
for (cx, cy, name) in &sig_points {
scene.add(Primitive::Text {
x: *cx,
y: cy - mp.point_size - 2.0,
content: name.clone(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
LabelStyle::Nudge => {
let mut labels: Vec<(f64, f64, String)> = sig_points
.iter()
.map(|(cx, cy, name)| (*cx, cy - mp.point_size - 2.0, name.clone()))
.collect();
labels.sort_by(|a, b| a.0.total_cmp(&b.0));
let min_gap = computed.body_size as f64 + 2.0;
for j in 1..labels.len() {
let prev_y = labels[j - 1].1;
if (prev_y - labels[j].1).abs() < min_gap {
labels[j].1 = prev_y - min_gap;
}
}
for (cx, label_y, name) in &labels {
scene.add(Primitive::Text {
x: *cx,
y: *label_y,
content: name.clone(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
LabelStyle::Arrow { offset_x, offset_y } => {
for (cx, cy, name) in &sig_points {
let text_x = cx + offset_x;
let text_y = cy - offset_y;
let dx = cx - text_x;
let dy = cy - text_y;
let len = (dx * dx + dy * dy).sqrt();
if len > mp.point_size + 3.0 {
let scale = (len - mp.point_size - 3.0) / len;
let end_x = text_x + dx * scale;
let end_y = text_y + dy * scale;
scene.add(Primitive::Line {
x1: text_x,
y1: text_y,
x2: end_x,
y2: end_y,
stroke: "#666666".into(),
stroke_width: 0.8,
stroke_dasharray: None,
});
}
let anchor = if offset_x >= 0.0 {
TextAnchor::Start
} else {
TextAnchor::End
};
scene.add(Primitive::Text {
x: text_x,
y: text_y,
content: name.clone(),
size: computed.body_size,
anchor,
rotate: None,
bold: false,
color: None,
});
}
}
}
}
pub fn render_volcano(vp: &VolcanoPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_volcano(vp, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_manhattan_chr_labels(mp: &ManhattanPlot, scene: &mut Scene, computed: &ComputedLayout) {
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let label_y = computed.height - computed.margin_bottom + 5.0 + computed.tick_size as f64;
let min_label_px = 6.0_f64;
for span in &mp.spans {
let band_px = (computed.map_x(span.x_end) - computed.map_x(span.x_start)).abs();
let mid_x = computed.map_x((span.x_start + span.x_end) / 2.0);
if mid_x >= plot_left && mid_x <= plot_right && band_px >= min_label_px {
let (anchor, rotate) = match computed.x_tick_rotate {
Some(angle) => (TextAnchor::End, Some(angle)),
None => (TextAnchor::Middle, None),
};
scene.add(Primitive::Text {
x: mid_x,
y: label_y,
content: span.name.clone(),
size: computed.tick_size,
anchor,
rotate,
bold: false,
color: None,
});
}
}
}
pub fn render_manhattan(mp: &ManhattanPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_manhattan(mp, &mut scene, &computed);
add_manhattan_chr_labels(mp, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_scatter(scatter: &ScatterPlot, layout: Layout) -> Scene {
let computed = ComputedLayout::from_layout(&layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, &layout);
add_labels_and_title(&mut scene, &computed, &layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_scatter(scatter, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_line(line: &LinePlot, layout: Layout) -> Scene {
let computed = ComputedLayout::from_layout(&layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, &layout);
add_labels_and_title(&mut scene, &computed, &layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_line(line, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_bar(bar: &BarPlot, layout: Layout) -> Scene {
let computed = ComputedLayout::from_layout(&layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, &layout);
add_labels_and_title(&mut scene, &computed, &layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_bar(bar, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_bar_categories(bar: &BarPlot, layout: Layout) -> Scene {
let computed = ComputedLayout::from_layout(&layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, &layout);
add_labels_and_title(&mut scene, &computed, &layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_bar(bar, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_histogram(hist: &Histogram, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_histogram(hist, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_boxplot(boxplot: &BoxPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_boxplot(boxplot, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_violin(violin: &ViolinPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_violin(violin, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_pie(pie: &PiePlot, layout: &Layout) -> Scene {
let mut computed = ComputedLayout::from_layout(layout);
let has_outside = matches!(
pie.label_position,
PieLabelPosition::Outside | PieLabelPosition::Auto
);
if has_outside {
let total: f64 = pie.slices.iter().map(|s| s.value).sum();
let char_width = computed.body_size as f64 * 0.6;
let max_label_px = pie
.slices
.iter()
.map(|slice| {
let frac = slice.value / total;
let place_inside = match pie.label_position {
PieLabelPosition::None | PieLabelPosition::Inside => true,
PieLabelPosition::Outside => false,
PieLabelPosition::Auto => frac >= pie.min_label_fraction,
};
if place_inside {
return 0.0;
}
let label_text = if pie.show_percent {
let pct = frac * 100.0;
if slice.label.is_empty() {
format!("{:.1}%", pct)
} else {
format!("{} ({:.1}%)", slice.label, pct)
}
} else {
slice.label.clone()
};
label_text.len() as f64 * char_width
})
.fold(0.0f64, f64::max);
let leader_gap = 30.0;
let pad = 5.0;
let safety = 20.0;
let radius = computed.plot_height() / 2.0 - pad;
let needed_half = radius + leader_gap + max_label_px + pad + safety;
let needed_plot_width = needed_half * 2.0;
if needed_plot_width > computed.plot_width() {
computed.width = needed_plot_width + computed.margin_left + computed.margin_right;
computed.recompute_transforms();
}
}
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_pie(pie, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_brickplot(brickplot: &BrickPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_brickplot(brickplot, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_density(dp: &DensityPlot, computed: &ComputedLayout, scene: &mut Scene) {
use render_utils::{silverman_bandwidth, simple_kde, simple_kde_reflect};
let curve: Vec<(f64, f64)> = if let Some((xs, ys)) = &dp.precomputed {
xs.iter().copied().zip(ys.iter().copied()).collect()
} else {
if dp.data.len() < 2 {
return;
}
let bw = dp
.bandwidth
.unwrap_or_else(|| silverman_bandwidth(&dp.data));
let n = dp.data.len() as f64;
let norm = 1.0 / (n * bw * (2.0 * std::f64::consts::PI).sqrt());
let raw = if dp.x_lo.is_some() || dp.x_hi.is_some() {
let data_min = dp.data.iter().cloned().fold(f64::INFINITY, f64::min);
let data_max = dp.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let lo = dp.x_lo.unwrap_or(data_min - 3.0 * bw);
let hi = dp.x_hi.unwrap_or(data_max + 3.0 * bw);
simple_kde_reflect(
&dp.data,
bw,
dp.kde_samples,
lo,
hi,
dp.x_lo.is_some(),
dp.x_hi.is_some(),
)
} else {
simple_kde(&dp.data, bw, dp.kde_samples)
};
raw.into_iter().map(|(x, y)| (x, y * norm)).collect()
};
if curve.is_empty() {
return;
}
let pts: Vec<(f64, f64)> = curve
.iter()
.map(|&(x, y)| (computed.map_x(x), computed.map_y(y)))
.collect();
let mut path = String::with_capacity(pts.len() * 16);
let mut rb = ryu::Buffer::new();
for (i, &(px, py)) in pts.iter().enumerate() {
path.push(if i == 0 { 'M' } else { 'L' });
path.push(' ');
path.push_str(rb.format(round2(px)));
path.push(' ');
path.push_str(rb.format(round2(py)));
path.push(' ');
}
if dp.filled {
let y_baseline = computed.map_y(0.0);
let &(last_px, _) = pts.last().unwrap();
let &(first_px, _) = pts.first().unwrap();
let fill_path = format!(
"{} L {} {} L {} {} Z",
path.trim_end(),
round2(last_px),
round2(y_baseline),
round2(first_px),
round2(y_baseline),
);
scene.add(Primitive::Path(Box::new(PathData {
d: fill_path,
fill: Some(Color::from(&dp.color)),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(dp.opacity),
stroke_dasharray: None,
})));
}
scene.add(Primitive::Path(Box::new(PathData {
d: path.trim_end().to_string(),
fill: None,
stroke: Color::from(&dp.color),
stroke_width: dp.stroke_width,
opacity: None,
stroke_dasharray: dp.line_dash.clone(),
})));
}
fn add_ecdf(ep: &crate::plot::ecdf::EcdfPlot, computed: &ComputedLayout, scene: &mut Scene) {
use crate::render::palette::Palette;
use render_utils::{silverman_bandwidth, simple_kde};
if ep.groups.is_empty() {
return;
}
let cat10 = Palette::category10();
let plot_x1 = computed.margin_left;
let plot_x2 = computed.margin_left + computed.plot_width();
for &p in &ep.percentile_lines {
let y_val = if ep.complementary { 1.0 - p } else { p };
let py = computed.map_y(y_val);
scene.add(Primitive::Line {
x1: plot_x1,
y1: py,
x2: plot_x2,
y2: py,
stroke: Color::from("#888888"),
stroke_width: 0.8,
stroke_dasharray: Some("4,4".into()),
});
let pct_str = format!("{}%", (p * 100.0).round() as u32);
scene.add(Primitive::Text {
x: plot_x2 + 3.0,
y: py + computed.tick_size as f64 * 0.4,
content: pct_str,
size: computed.tick_size.saturating_sub(2).max(7),
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(Color::from("#888888")),
});
}
for (i, group) in ep.groups.iter().enumerate() {
if group.data.is_empty() {
continue;
}
let color_str = group.color.as_deref().unwrap_or_else(|| {
if ep.groups.len() == 1 {
&ep.color
} else {
&cat10[i % cat10.len()]
}
});
let color = Color::from(color_str);
let mut sorted = group.data.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
let n = sorted.len();
if ep.show_confidence_band && n >= 2 {
let eps = ((2.0_f64.ln() - 0.05_f64.ln()) / (2.0 * n as f64)).sqrt();
let mut upper: Vec<(f64, f64)> = Vec::with_capacity(n * 2 + 2);
let mut lower: Vec<(f64, f64)> = Vec::with_capacity(n * 2 + 2);
let px0 = computed.map_x(sorted[0]);
let (uy0, ly0) = if ep.complementary {
(
computed.map_y(1.0_f64.min(1.0 + eps)),
computed.map_y(0.0_f64.max(1.0 - eps)),
)
} else {
(computed.map_y(eps.min(1.0)), computed.map_y(0.0))
};
upper.push((px0, uy0));
lower.push((px0, ly0));
for (idx, &x) in sorted.iter().enumerate() {
let f = (idx + 1) as f64 / n as f64;
let (y_upper, y_lower) = if ep.complementary {
((1.0 - f + eps).min(1.0), (1.0 - f - eps).max(0.0))
} else {
((f + eps).min(1.0), (f - eps).max(0.0))
};
let px = computed.map_x(x);
upper.push((px, upper.last().unwrap().1));
upper.push((px, computed.map_y(y_upper)));
lower.push((px, lower.last().unwrap().1));
lower.push((px, computed.map_y(y_lower)));
}
let mut d = format!("M {},{}", round2(upper[0].0), round2(upper[0].1));
for &(x, y) in upper.iter().skip(1) {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
for &(x, y) in lower.iter().rev() {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
d.push_str(" Z");
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.clone()),
stroke: color.clone(),
stroke_width: 0.0,
opacity: Some(ep.band_alpha),
stroke_dasharray: None,
})));
}
if ep.smooth && n >= 2 {
let bw = silverman_bandwidth(&sorted);
let norm = 1.0 / (n as f64 * bw * (2.0 * std::f64::consts::PI).sqrt());
let kde_pts = simple_kde(&sorted, bw, ep.smooth_samples);
let pdf: Vec<(f64, f64)> = kde_pts.into_iter().map(|(x, y)| (x, y * norm)).collect();
let m = pdf.len();
let mut cdf = vec![0.0f64; m];
for j in 1..m {
let dx = pdf[j].0 - pdf[j - 1].0;
cdf[j] = cdf[j - 1] + 0.5 * (pdf[j].1 + pdf[j - 1].1) * dx;
}
let total = cdf[m - 1].max(1e-12);
let pts: Vec<(f64, f64)> = pdf
.iter()
.zip(cdf.iter())
.map(|(&(x, _), &c)| {
let y = (c / total).clamp(0.0, 1.0);
let y = if ep.complementary { 1.0 - y } else { y };
(computed.map_x(x), computed.map_y(y))
})
.collect();
if !pts.is_empty() {
let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
for &(px, py) in pts.iter().skip(1) {
d.push_str(&format!(" L {},{}", round2(px), round2(py)));
}
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: color.clone(),
stroke_width: ep.stroke_width,
opacity: None,
stroke_dasharray: ep.line_dash.clone(),
})));
}
} else {
let y_start = if ep.complementary { 1.0 } else { 0.0 };
let mut d = format!(
"M {},{}",
round2(computed.map_x(sorted[0])),
round2(computed.map_y(y_start))
);
for (idx, &x) in sorted.iter().enumerate() {
let y_after = (idx + 1) as f64 / n as f64;
let y_val = if ep.complementary {
1.0 - y_after
} else {
y_after
};
if idx > 0 {
d.push_str(&format!(" H {}", round2(computed.map_x(x))));
}
d.push_str(&format!(" V {}", round2(computed.map_y(y_val))));
}
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: color.clone(),
stroke_width: ep.stroke_width,
opacity: None,
stroke_dasharray: ep.line_dash.clone(),
})));
}
if ep.show_markers {
for (idx, &x) in sorted.iter().enumerate() {
let y_val = (idx + 1) as f64 / n as f64;
let y_val = if ep.complementary { 1.0 - y_val } else { y_val };
scene.add(Primitive::Circle {
cx: computed.map_x(x),
cy: computed.map_y(y_val),
r: ep.marker_size,
fill: color.clone(),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
if ep.show_rug {
let y_bottom = computed.map_y(0.0);
let rug_offset = i as f64 * (ep.rug_height + 1.5);
for &x in &sorted {
let px = computed.map_x(x);
scene.add(Primitive::Line {
x1: px,
y1: y_bottom - rug_offset,
x2: px,
y2: y_bottom - rug_offset - ep.rug_height,
stroke: color.clone(),
stroke_width: 0.8,
stroke_dasharray: None,
});
}
}
}
}
fn add_qqplot(qp: &crate::plot::qq::QQPlot, computed: &ComputedLayout, scene: &mut Scene) {
use crate::plot::qq::QQMode;
use crate::render::palette::Palette;
use crate::render::render_utils::{percentile, probit};
if qp.groups.is_empty() {
return;
}
let cat10 = Palette::category10();
let resolve_color = |group: &crate::plot::qq::QQGroup, idx: usize| -> Color {
let s = group.color.clone().unwrap_or_else(|| {
if qp.groups.len() == 1 {
qp.color.clone()
} else {
cat10[idx % cat10.len()].to_string()
}
});
Color::from(s.as_str())
};
match &qp.mode {
QQMode::Normal => {
for (gi, group) in qp.groups.iter().enumerate() {
let color = resolve_color(group, gi);
let mut sorted = group.data.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = sorted.len();
if n == 0 {
continue;
}
let theoretical: Vec<f64> = (1..=n)
.map(|k| probit((k as f64 - 0.5) / n as f64))
.collect();
if qp.show_reference_line {
let q1_th = probit(0.25_f64);
let q3_th = probit(0.75_f64);
let q1_s = percentile(&sorted, 25.0);
let q3_s = percentile(&sorted, 75.0);
if (q3_th - q1_th).abs() > 1e-12 {
let slope = (q3_s - q1_s) / (q3_th - q1_th);
let intercept = q1_s - slope * q1_th;
let x0 = theoretical[0];
let x1 = *theoretical.last().unwrap();
let ref_color = Color::from("#999999");
scene.add(Primitive::Line {
x1: computed.map_x(x0),
y1: computed.map_y(slope * x0 + intercept),
x2: computed.map_x(x1),
y2: computed.map_y(slope * x1 + intercept),
stroke: ref_color,
stroke_width: qp.stroke_width,
stroke_dasharray: Some("5,3".into()),
});
}
}
let fill_op = qp.fill_opacity;
for k in 0..n {
scene.add(Primitive::Circle {
cx: computed.map_x(theoretical[k]),
cy: computed.map_y(sorted[k]),
r: qp.marker_size,
fill: color.clone(),
fill_opacity: fill_op,
stroke: None,
stroke_width: None,
});
}
}
}
QQMode::Genomic => {
let first_n = qp
.groups
.first()
.map(|g| g.data.iter().filter(|&&p| p > 0.0 && p <= 1.0).count())
.unwrap_or(0);
if qp.show_ci_band && first_n > 1 {
let n = first_n;
let step = (n / 500).max(1);
let mut upper_pts: Vec<(f64, f64)> = Vec::new();
let mut lower_pts: Vec<(f64, f64)> = Vec::new();
for idx in (0..n).step_by(step) {
let i = idx + 1; let expected_p = (i as f64 - 0.5) / n as f64;
let x_val = -expected_p.log10();
let mean = i as f64 / (n as f64 + 1.0);
let var = (i as f64 * (n - i + 1) as f64)
/ ((n as f64 + 1.0).powi(2) * (n as f64 + 2.0));
let se = var.sqrt();
let lower_p = (mean - 1.96 * se).max(1e-300);
let upper_p = (mean + 1.96 * se).min(1.0 - 1e-10);
let y_upper = -lower_p.log10();
let y_lower = -upper_p.log10();
upper_pts.push((computed.map_x(x_val), computed.map_y(y_upper)));
lower_pts.push((computed.map_x(x_val), computed.map_y(y_lower)));
}
if upper_pts.len() >= 2 {
let mut d = format!("M {},{}", round2(upper_pts[0].0), round2(upper_pts[0].1));
for &(x, y) in upper_pts.iter().skip(1) {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
for &(x, y) in lower_pts.iter().rev() {
d.push_str(&format!(" L {},{}", round2(x), round2(y)));
}
d.push_str(" Z");
let band_color = if qp.groups.len() == 1 {
resolve_color(&qp.groups[0], 0)
} else {
Color::from("#aaaaaa")
};
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(band_color),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(qp.ci_alpha),
stroke_dasharray: None,
})));
}
}
if qp.show_reference_line {
let max_x = qp
.groups
.iter()
.flat_map(|g| g.data.iter())
.filter(|&&p| p > 0.0 && p <= 1.0)
.map(|&p| -p.log10())
.fold(0.0_f64, f64::max);
let max_n = qp
.groups
.iter()
.map(|g| g.data.iter().filter(|&&p| p > 0.0 && p <= 1.0).count())
.max()
.unwrap_or(1);
let max_exp = if max_n > 0 {
-(0.5 / max_n as f64).log10()
} else {
1.0
};
let diag_max = max_x.max(max_exp);
scene.add(Primitive::Line {
x1: computed.map_x(0.0),
y1: computed.map_y(0.0),
x2: computed.map_x(diag_max),
y2: computed.map_y(diag_max),
stroke: Color::from("#999999"),
stroke_width: qp.stroke_width,
stroke_dasharray: Some("5,3".into()),
});
}
for (gi, group) in qp.groups.iter().enumerate() {
let color = resolve_color(group, gi);
let mut pvals: Vec<f64> = group
.data
.iter()
.copied()
.filter(|&p| p > 0.0 && p <= 1.0)
.collect();
pvals.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = pvals.len();
if n == 0 {
continue;
}
let fill_op = qp.fill_opacity;
for (k, &pval) in pvals.iter().enumerate() {
let expected_p = (k as f64 + 0.5) / n as f64;
let x_val = -expected_p.log10();
let y_val = -pval.log10();
scene.add(Primitive::Circle {
cx: computed.map_x(x_val),
cy: computed.map_y(y_val),
r: qp.marker_size,
fill: color.clone(),
fill_opacity: fill_op,
stroke: None,
stroke_width: None,
});
}
if qp.show_lambda && !pvals.is_empty() {
let mut chi2: Vec<f64> = pvals
.iter()
.map(|&p| {
let z = probit(1.0 - (p / 2.0).min(1.0 - 1e-15));
z * z
})
.collect();
chi2.sort_by(|a, b| a.partial_cmp(b).unwrap());
let lambda = percentile(&chi2, 50.0) / 0.4549;
let label_x = computed.margin_left + computed.plot_width() * 0.05;
let body_px = computed.body_size as f64;
let label_y = computed.margin_top + body_px * 1.5 + gi as f64 * (body_px + 4.0);
let lambda_label = if qp.groups.len() > 1 {
format!("{} λ = {:.3}", group.label, lambda)
} else {
format!("λ = {:.3}", lambda)
};
scene.add(Primitive::Text {
x: label_x,
y: label_y,
content: lambda_label,
size: computed.body_size,
color: Some(color.clone()),
anchor: TextAnchor::Start,
bold: false,
rotate: None,
});
}
}
}
}
}
fn add_ridgeline(rp: &RidgelinePlot, computed: &ComputedLayout, scene: &mut Scene) {
use crate::render::palette::Palette;
use render_utils::{silverman_bandwidth, simple_kde};
let fallback = Palette::category10();
let n = rp.groups.len();
if n == 0 {
return;
}
let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
let ridge_h_px = cell_h_px * (1.0 + rp.overlap);
for (i, group) in rp.groups.iter().enumerate() {
if group.values.len() < 2 {
continue;
}
let color = group
.color
.as_deref()
.unwrap_or_else(|| &fallback[i % fallback.len()]);
let bw = rp
.bandwidth
.unwrap_or_else(|| silverman_bandwidth(&group.values));
let raw = simple_kde(&group.values, bw, rp.kde_samples);
let max_d = raw.iter().map(|&(_, d)| d).fold(0.0_f64, f64::max);
if max_d == 0.0 {
continue;
}
let y_center_data = (n - i) as f64;
let y_center_px = computed.map_y(y_center_data);
let scale = if rp.normalize {
let nf = group.values.len() as f64;
let norm_factor = 1.0 / (nf * bw * (2.0 * std::f64::consts::PI).sqrt());
let max_normed = max_d * norm_factor;
if max_normed > 0.0 {
ridge_h_px / max_normed
} else {
0.0
}
} else {
ridge_h_px / max_d
};
let pts: Vec<(f64, f64)> = raw
.iter()
.map(|&(x, d)| {
let normed = if rp.normalize {
let nf = group.values.len() as f64;
d / (nf * bw * (2.0 * std::f64::consts::PI).sqrt())
} else {
d
};
(computed.map_x(x), y_center_px - normed * scale)
})
.collect();
if pts.is_empty() {
continue;
}
let mut rb = ryu::Buffer::new();
if rp.show_baseline {
scene.add(Primitive::Line {
x1: computed.margin_left,
y1: y_center_px,
x2: computed.width - computed.margin_right,
y2: y_center_px,
stroke: Color::from(&computed.theme.axis_color),
stroke_width: computed.axis_stroke_width * 0.5,
stroke_dasharray: None,
});
}
let mut outline = String::with_capacity(pts.len() * 16);
for (j, &(px, py)) in pts.iter().enumerate() {
outline.push(if j == 0 { 'M' } else { 'L' });
outline.push(' ');
outline.push_str(rb.format(round2(px)));
outline.push(' ');
outline.push_str(rb.format(round2(py)));
outline.push(' ');
}
let outline = outline.trim_end().to_string();
if rp.filled {
let first_px = pts.first().unwrap().0;
let last_px = pts.last().unwrap().0;
let s_last_px = rb.format(round2(last_px)).to_string();
let s_y_center = rb.format(round2(y_center_px)).to_string();
let s_first_px = rb.format(round2(first_px)).to_string();
let fill_path = format!(
"{} L {} {} L {} {} Z",
outline, s_last_px, s_y_center, s_first_px, s_y_center,
);
scene.add(Primitive::Path(Box::new(PathData {
d: fill_path,
fill: Some(Color::from(color)),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(rp.opacity),
stroke_dasharray: None,
})));
}
scene.add(Primitive::Path(Box::new(PathData {
d: outline,
fill: None,
stroke: Color::from(color),
stroke_width: rp.stroke_width,
opacity: None,
stroke_dasharray: rp.line_dash.clone(),
})));
}
}
pub fn render_waterfall(waterfall: &WaterfallPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_waterfall(waterfall, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
pub fn render_strip(strip: &StripPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, layout);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_strip(strip, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_dot_plot(dp: &DotPlot, scene: &mut Scene, computed: &ComputedLayout) {
const EPSILON: f64 = f64::EPSILON;
let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
let (color_min, color_max) = dp.color_range.unwrap_or_else(|| dp.color_extent());
let n_x = dp.x_categories.len() as f64;
let n_y = dp.y_categories.len() as f64;
let n_y_usize = dp.y_categories.len();
let cell_w = if n_x > 0.0 {
computed.plot_width() / n_x
} else {
1.0
};
let cell_h = if n_y > 0.0 {
computed.plot_height() / n_y
} else {
1.0
};
let effective_max_r = dp.max_radius.min((cell_w.min(cell_h) / 2.0) * 0.9);
for (dpi, pt) in dp.points.iter().enumerate() {
let xi = dp.x_categories.iter().position(|c| c == &pt.x_cat);
let yi = dp.y_categories.iter().position(|c| c == &pt.y_cat);
let (xi, yi) = match (xi, yi) {
(Some(xi), Some(yi)) => (xi, yi),
_ => continue,
};
let cx = computed.map_x(xi as f64 + 1.0);
let cy = computed.map_y((n_y_usize - yi) as f64);
let norm_size = (pt.size - size_min) / (size_max - size_min + EPSILON);
let norm_color = (pt.color - color_min) / (color_max - color_min + EPSILON);
let r = dp.min_radius + norm_size.clamp(0.0, 1.0) * (effective_max_r - dp.min_radius);
let fill = dp.color_map.map(norm_color.clamp(0.0, 1.0));
let tip = tooltip(dp.show_tooltips, &dp.tooltip_labels, dpi, || {
format!("{}, {}: size={:.2}", pt.x_cat, pt.y_cat, pt.size)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Circle {
cx,
cy,
r,
fill: fill.into(),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
}
fn add_diceplot(dp: &DicePlot, scene: &mut Scene, computed: &ComputedLayout) {
const EPSILON: f64 = f64::EPSILON;
let n_x = dp.x_categories.len();
let n_y = dp.y_categories.len();
if n_x == 0 || n_y == 0 {
return;
}
let categorical_mode = dp.points.iter().any(|p| !p.dot_colors.is_empty());
let per_dot_mode = dp
.points
.iter()
.any(|p| !p.dot_fills.is_empty() || !p.dot_sizes.is_empty());
let tile_mode = dp
.points
.iter()
.any(|p| !p.present.is_empty() || p.fill.is_some() || p.size.is_some());
debug_assert!(
[categorical_mode, per_dot_mode, tile_mode]
.iter()
.filter(|&&v| v)
.count()
<= 1,
"DicePlot: mixing with_records / with_dot_data / with_points on the same plot \
is not supported and will produce unpredictable output. Use a single input mode."
);
let per_dot_mode = !categorical_mode && per_dot_mode;
let cell_sq = {
let cw = computed.plot_width() / n_x as f64;
let ch = computed.plot_height() / n_y as f64;
cw.min(ch)
};
let tile_sq = cell_sq * dp.cell_width.min(dp.cell_height);
let grid_total_w = n_x as f64 * cell_sq;
let grid_total_h = n_y as f64 * cell_sq;
let grid_x0 = computed.margin_left + (computed.plot_width() - grid_total_w) / 2.0;
let grid_y0 = computed.margin_top + (computed.plot_height() - grid_total_h) / 2.0;
let sub = tile_sq / 3.0; let pip_scale = 0.85_f64;
let max_pip_r = sub * 0.5 * pip_scale; let base_r = if dp.dot_radius > 0.0 {
dp.dot_radius
} else {
max_pip_r
};
let grid_positions = dp.dot_grid_positions();
let has_size = dp.points.iter().any(|p| p.size.is_some())
|| dp
.points
.iter()
.any(|p| p.dot_sizes.iter().any(|v| v.is_some()));
let has_fill = dp.points.iter().any(|p| p.fill.is_some());
let (fill_min, fill_max) = dp.fill_range.unwrap_or_else(|| dp.fill_extent());
let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
{
let theme = &computed.theme;
let ax = Color::from(&theme.axis_color);
let aw = computed.axis_stroke_width;
let tl = computed.tick_mark_major;
let tlm = computed.tick_label_margin;
let ts = computed.tick_size;
scene.add(Primitive::Line {
x1: grid_x0,
y1: grid_y0 + grid_total_h,
x2: grid_x0 + grid_total_w,
y2: grid_y0 + grid_total_h,
stroke: ax.clone(),
stroke_width: aw,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: grid_x0,
y1: grid_y0,
x2: grid_x0,
y2: grid_y0 + grid_total_h,
stroke: ax.clone(),
stroke_width: aw,
stroke_dasharray: None,
});
for (xi, label) in dp.x_categories.iter().enumerate() {
let tx = grid_x0 + (xi as f64 + 0.5) * cell_sq;
let ty = grid_y0 + grid_total_h;
scene.add(Primitive::Line {
x1: tx,
y1: ty,
x2: tx,
y2: ty + tl,
stroke: ax.clone(),
stroke_width: aw,
stroke_dasharray: None,
});
scene.add(Primitive::Text {
x: tx,
y: ty + tl + tlm + ts as f64 * 0.7,
content: label.clone(),
size: ts,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
for (yi, label) in dp.y_categories.iter().enumerate() {
let ty = grid_y0 + (yi as f64 + 0.5) * cell_sq;
scene.add(Primitive::Line {
x1: grid_x0 - tl,
y1: ty,
x2: grid_x0,
y2: ty,
stroke: ax.clone(),
stroke_width: aw,
stroke_dasharray: None,
});
scene.add(Primitive::Text {
x: grid_x0 - tl - tlm,
y: ty + ts as f64 * 0.35,
content: label.clone(),
size: ts,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
}
for pt in &dp.points {
let xi = dp.x_categories.iter().position(|c| c == &pt.x_cat);
let yi = dp.y_categories.iter().position(|c| c == &pt.y_cat);
let (xi, yi) = match (xi, yi) {
(Some(xi), Some(yi)) => (xi, yi),
_ => continue,
};
let cx = grid_x0 + (xi as f64 + 0.5) * cell_sq;
let cy = grid_y0 + (yi as f64 + 0.5) * cell_sq;
let theme = &computed.theme;
let (tile_fill, tile_stroke, tile_stroke_w): (Color, Option<Color>, Option<f64>) =
if categorical_mode || per_dot_mode {
(
Color::from(&theme.legend_bg),
Some(Color::from(&theme.axis_color)),
Some(0.8_f64),
)
} else if has_fill {
let color: Color = if let Some(v) = pt.fill {
let norm = (v - fill_min) / (fill_max - fill_min + EPSILON);
dp.color_map.map(norm.clamp(0.0, 1.0)).into()
} else {
"#e8e8e8".into()
};
(color, Some("#cccccc".into()), Some(0.5_f64))
} else {
("#e8e8e8".into(), Some("#cccccc".into()), Some(0.5_f64))
};
scene.add(Primitive::Rect {
x: cx - tile_sq / 2.0,
y: cy - tile_sq / 2.0,
width: tile_sq,
height: tile_sq,
fill: tile_fill,
stroke: tile_stroke,
stroke_width: tile_stroke_w,
opacity: None,
});
if dp.grid_lines {
let gl: Color = "#aaaaaa".into();
for i in 1..3_usize {
let frac = i as f64 / 3.0;
scene.add(Primitive::Line {
x1: cx - tile_sq / 2.0 + frac * tile_sq,
y1: cy - tile_sq / 2.0,
x2: cx - tile_sq / 2.0 + frac * tile_sq,
y2: cy + tile_sq / 2.0,
stroke: gl.clone(),
stroke_width: 0.4,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: cx - tile_sq / 2.0,
y1: cy - tile_sq / 2.0 + frac * tile_sq,
x2: cx + tile_sq / 2.0,
y2: cy - tile_sq / 2.0 + frac * tile_sq,
stroke: gl.clone(),
stroke_width: 0.4,
stroke_dasharray: None,
});
}
}
let cell_dot_r = if has_size && !categorical_mode && !per_dot_mode {
if let Some(s) = pt.size {
let norm = (s - size_min) / (size_max - size_min + EPSILON);
base_r * (0.25 + 0.75 * norm.clamp(0.0, 1.0))
} else {
base_r * 0.25
}
} else {
base_r
};
for k in 0..dp.ndots {
let (h_idx, v_idx) = match grid_positions.get(k) {
Some(&p) => p,
None => continue,
};
let dot_cx = cx + (h_idx as f64 - 1.0) * sub;
let dot_cy = cy + (v_idx as f64 - 1.0) * sub;
if categorical_mode {
if let Some(color) = pt.dot_colors.get(k).and_then(|c| c.as_deref()) {
scene.add(Primitive::Circle {
cx: dot_cx,
cy: dot_cy,
r: cell_dot_r,
fill: color.into(),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
} else if per_dot_mode {
if let Some(fill_v) = pt.dot_fills.get(k).and_then(|v| *v) {
let fill_norm = (fill_v - fill_min) / (fill_max - fill_min + EPSILON);
let fill_color: Color = dp.color_map.map(fill_norm.clamp(0.0, 1.0)).into();
let dot_r = if let Some(size_v) = pt.dot_sizes.get(k).and_then(|v| *v) {
let size_norm = (size_v - size_min) / (size_max - size_min + EPSILON);
base_r * (0.25 + 0.75 * size_norm.clamp(0.0, 1.0))
} else {
base_r * 0.25
};
scene.add(Primitive::Circle {
cx: dot_cx,
cy: dot_cy,
r: dot_r,
fill: fill_color,
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
} else if pt.present.contains(&k) {
scene.add(Primitive::Circle {
cx: dot_cx,
cy: dot_cy,
r: cell_dot_r,
fill: "#222222".into(),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
} else {
let r = cell_dot_r * 0.6;
let d = format!(
"M {},{} A {},{} 0 1,0 {},{} A {},{} 0 1,0 {},{} Z",
dot_cx - r,
dot_cy,
r,
r,
dot_cx + r,
dot_cy,
r,
r,
dot_cx - r,
dot_cy,
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some("none".into()),
stroke: "#999999".into(),
stroke_width: 0.8,
opacity: Some(0.6),
stroke_dasharray: None,
})));
}
}
}
}
fn add_dice_position_legend(
dp: &DicePlot,
title: &str,
scene: &mut Scene,
computed: &ComputedLayout,
y_start: f64,
) -> f64 {
let theme = &computed.theme;
let legend_padding = 10.0;
let legend_width = computed.legend_width;
let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
let max_cat_len = dp
.category_labels
.iter()
.map(|l| l.len())
.max()
.unwrap_or(3);
let die_cell_w = (max_cat_len as f64 * 5.5 + 10.0).max(24.0_f64);
let die_cell_pip_h = 18.0_f64; let label_area_h = 14.0_f64; let row_h = die_cell_pip_h + label_area_h;
let die_w = 3.0 * die_cell_w;
let die_h = 3.0 * row_h;
let pip_r = 4.5_f64;
let label_size = (computed.body_size as i32 - 1).max(9) as u32;
let title_h = computed.body_size as f64 + 8.0;
let box_height = legend_padding * 2.0 + title_h + die_h;
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: y_start - legend_padding,
width: legend_width,
height: box_height,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: y_start - legend_padding,
width: legend_width,
height: box_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(1.0),
opacity: None,
});
scene.add(Primitive::Text {
x: legend_x + legend_width * 0.5 - legend_padding,
y: y_start + computed.body_size as f64 * 0.85,
content: title.to_string(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
let die_x = legend_x + (legend_width - legend_padding * 2.0 - die_w) / 2.0;
let die_y = y_start + title_h;
scene.add(Primitive::Rect {
x: die_x,
y: die_y,
width: die_w,
height: die_h,
fill: Color::from(&theme.legend_bg),
stroke: Some(Color::from(&theme.axis_color)),
stroke_width: Some(0.8),
opacity: None,
});
let grid_color: Color = Color::from(&theme.axis_color);
for i in 1..3_usize {
scene.add(Primitive::Line {
x1: die_x + i as f64 * die_cell_w,
y1: die_y,
x2: die_x + i as f64 * die_cell_w,
y2: die_y + die_h,
stroke: grid_color.clone(),
stroke_width: 0.4,
stroke_dasharray: Some("3,3".to_string()),
});
scene.add(Primitive::Line {
x1: die_x,
y1: die_y + i as f64 * row_h,
x2: die_x + die_w,
y2: die_y + i as f64 * row_h,
stroke: grid_color.clone(),
stroke_width: 0.4,
stroke_dasharray: Some("3,3".to_string()),
});
}
for (k, (grid_row, grid_col)) in dp.dot_grid_positions().iter().enumerate() {
let pip_cx = die_x + *grid_col as f64 * die_cell_w + die_cell_w / 2.0;
let pip_cy = die_y + *grid_row as f64 * row_h + die_cell_pip_h / 2.0;
scene.add(Primitive::Circle {
cx: pip_cx,
cy: pip_cy,
r: pip_r,
fill: Color::from(&theme.text_color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
let label = dp.category_labels.get(k).map(|s| s.as_str()).unwrap_or("");
scene.add(Primitive::Text {
x: pip_cx,
y: pip_cy + die_cell_pip_h / 2.0 + label_area_h * 0.8,
content: label.to_string(),
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
y_start + box_height
}
fn add_dice_size_legend_section(
dp: &DicePlot,
title: &str,
scene: &mut Scene,
computed: &ComputedLayout,
y_start: f64,
) -> f64 {
let theme = &computed.theme;
let legend_padding = 10.0;
let line_height = computed.legend_line_height;
let legend_width = computed.legend_width;
let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
let n_rows = 3_usize;
let pcts: [f64; 3] = [0.25, 0.50, 1.0];
let box_height = (1 + n_rows) as f64 * line_height + legend_padding * 2.0;
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: y_start - legend_padding,
width: legend_width,
height: box_height,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: y_start - legend_padding,
width: legend_width,
height: box_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(1.0),
opacity: None,
});
scene.add(Primitive::Text {
x: legend_x + legend_width * 0.5 - legend_padding,
y: y_start + computed.body_size as f64 * 0.8,
content: title.to_string(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
let cell_sq = {
let cw = computed.plot_width() / dp.x_categories.len().max(1) as f64;
let ch = computed.plot_height() / dp.y_categories.len().max(1) as f64;
cw.min(ch)
};
let tile_sq = cell_sq * dp.cell_width.min(dp.cell_height);
let base_r = if dp.dot_radius > 0.0 {
dp.dot_radius
} else {
tile_sq / 6.0 * 0.85
};
let swatch_cx = legend_x + 5.0 + 10.0;
let mut row_y = y_start + line_height;
for &pct in &pcts {
let r = (base_r * (0.25 + 0.75 * pct)).clamp(2.0, 8.0);
let circle_cy = row_y + line_height * 0.5 - 2.0;
scene.add(Primitive::Circle {
cx: swatch_cx,
cy: circle_cy,
r,
fill: "#444444".into(),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
let value = size_min + pct * (size_max - size_min);
scene.add(Primitive::Text {
x: swatch_cx + 14.0,
y: circle_cy + computed.body_size as f64 / 3.0,
content: format!("{:.1}", value),
size: computed.body_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
row_y += line_height;
}
y_start + box_height
}
fn add_dice_legends(dp: &DicePlot, scene: &mut Scene, computed: &ComputedLayout) -> bool {
let mut y = computed.margin_top;
if let Some(ref title) = dp.position_legend_label {
y = add_dice_position_legend(dp, title, scene, computed, y);
y += 8.0;
}
if !dp.dot_legend.is_empty() {
let entries: Vec<LegendEntry> = dp
.dot_legend
.iter()
.map(|(label, color)| LegendEntry {
label: label.clone(),
color: color.clone(),
shape: LegendShape::Circle,
dasharray: None,
})
.collect();
let legend = Legend {
title: None,
entries,
groups: None,
position: computed.legend_position,
show_box: true,
};
add_legend_at(&legend, scene, computed, y);
y += legend.entries.len() as f64 * computed.legend_line_height + 28.0;
}
if let Some(ref title) = dp.size_legend_label {
y = add_dice_size_legend_section(dp, title, scene, computed, y);
y += 8.0;
}
if dp.fill_legend_label.is_some() {
let (fill_min, fill_max) = dp.fill_range.unwrap_or_else(|| dp.fill_extent());
let cmap = dp.color_map.clone();
let info = ColorBarInfo {
map_fn: std::sync::Arc::new(move |t| {
let norm = (t - fill_min) / (fill_max - fill_min + f64::EPSILON);
cmap.map(norm.clamp(0.0, 1.0))
}),
min_value: fill_min,
max_value: fill_max,
label: dp.fill_legend_label.clone(),
tick_labels: None,
};
let bar_x = computed.width - computed.colorbar_x_inset;
let available_below = computed.height - y - 28.0;
let bar_y = if available_below < 120.0 {
computed.margin_top + 8.0
} else {
y + 8.0
};
let bar_height = (computed.height - bar_y - 20.0).max(60.0);
add_colorbar_at(&info, scene, computed, bar_x, bar_y, bar_height);
return true;
}
false
}
fn add_dot_stacked_legends(
size_title: &str,
size_entries: &[LegendEntry],
info: &ColorBarInfo,
scene: &mut Scene,
computed: &ComputedLayout,
) {
let theme = &computed.theme;
let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
let legend_width = computed.legend_width;
let line_height = computed.legend_line_height;
let legend_padding = computed.legend_padding;
let title_y = computed.margin_top + computed.tick_size as f64;
let box_top = title_y + legend_padding + 4.0;
let size_legend_height = size_entries.len() as f64 * line_height + legend_padding * 2.0;
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: box_top - legend_padding,
width: legend_width,
height: size_legend_height,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: legend_x - legend_padding + 5.0,
y: box_top - legend_padding,
width: legend_width,
height: size_legend_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(computed.axis_stroke_width),
opacity: None,
});
scene.add(Primitive::Text {
x: legend_x + legend_width * 0.5 - legend_padding,
y: title_y,
content: size_title.to_string(),
size: computed.tick_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
let mut legend_y = box_top;
for entry in size_entries {
let swatch_cy = legend_y + computed.legend_swatch_size / 2.0 - 1.0;
let text_baseline = swatch_cy + computed.body_size as f64 * 0.35;
scene.add(Primitive::Text {
x: legend_x + computed.legend_text_x,
y: text_baseline,
content: entry.label.clone(),
size: computed.body_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
if let LegendShape::CircleSize(r) = entry.shape {
scene.add(Primitive::Circle {
cx: legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
cy: swatch_cy,
r: r.min(computed.legend_swatch_half),
fill: Color::from(&entry.color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
legend_y += line_height;
}
let gap = 15.0;
let bar_x = legend_x;
let bar_width = computed.colorbar_bar_width;
let colorbar_top = box_top - legend_padding + size_legend_height + gap;
let bar_y = colorbar_top;
let bar_height = (computed.height - computed.margin_bottom - bar_y - gap).max(50.0);
let num_slices = 50;
let slice_height = bar_height / num_slices as f64;
for i in 0..num_slices {
let t = 1.0 - (i as f64 / (num_slices - 1) as f64);
let value = info.min_value + t * (info.max_value - info.min_value);
let color = (info.map_fn)(value);
let y = bar_y + i as f64 * slice_height;
scene.add(Primitive::Rect {
x: bar_x,
y,
width: bar_width,
height: slice_height + 0.5,
fill: color.into(),
stroke: None,
stroke_width: None,
opacity: None,
});
}
scene.add(Primitive::Rect {
x: bar_x,
y: bar_y,
width: bar_width,
height: bar_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.colorbar_border)),
stroke_width: Some(computed.axis_stroke_width),
opacity: None,
});
let ticks = render_utils::generate_ticks(info.min_value, info.max_value, 5);
let range = info.max_value - info.min_value;
for tick in &ticks {
if *tick < info.min_value || *tick > info.max_value {
continue;
}
let frac = (tick - info.min_value) / range;
let y = bar_y + bar_height - frac * bar_height;
scene.add(Primitive::Line {
x1: bar_x + bar_width,
y1: y,
x2: bar_x + bar_width + computed.tick_mark_major * 0.8,
y2: y,
stroke: Color::from(&theme.colorbar_border),
stroke_width: computed.axis_stroke_width,
stroke_dasharray: None,
});
scene.add(Primitive::Text {
x: bar_x + bar_width + computed.tick_mark_major,
y: y + 4.0,
content: computed.colorbar_tick_format.format(*tick),
size: computed.tick_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
if let Some(ref label) = info.label {
let label_x = bar_x - computed.tick_size as f64 * 0.5 - 4.0;
let label_y = bar_y + bar_height / 2.0;
scene.add(Primitive::Text {
x: label_x,
y: label_y,
content: label.clone(),
size: computed.tick_size,
anchor: TextAnchor::Middle,
rotate: Some(-90.0),
bold: false,
color: None,
});
}
}
#[allow(clippy::collapsible_match)]
pub fn collect_legend_entries(plots: &[Plot]) -> Vec<LegendEntry> {
let mut entries = Vec::new();
for plot in plots {
match plot {
Plot::Bar(barplot) => {
if let Some(label) = barplot.legend_label.clone() {
for (i, barval) in barplot
.groups
.first()
.expect("BarPlot legend requires at least one group")
.bars
.iter()
.enumerate()
{
entries.push(LegendEntry {
label: label
.get(i)
.expect("BarPlot legend label count does not match bar count")
.to_string(),
color: barval.color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Line(line) => {
if let Some(label) = &line.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: line.color.clone(),
shape: LegendShape::Line,
dasharray: line.line_style.dasharray(),
});
}
}
Plot::Scatter(scatter) => {
if let Some(label) = &scatter.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: scatter.color.clone(),
shape: LegendShape::Marker(scatter.marker),
dasharray: None,
});
}
}
Plot::Series(series) => {
if let Some(label) = &series.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: series.color.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
}
}
Plot::Brick(brickplot) => {
let labels = brickplot
.template
.as_ref()
.expect("BrickPlot legend requires a template colormap");
let motifs = brickplot.motifs.as_ref();
let mut sorted_labels: Vec<(&char, &String)> = labels.iter().collect();
sorted_labels.sort_by(|(a, _), (b, _)| match (*a, *b) {
('@', '@') => std::cmp::Ordering::Equal,
('@', _) => std::cmp::Ordering::Greater,
(_, '@') => std::cmp::Ordering::Less,
_ => a.cmp(b),
});
for (letter, color) in sorted_labels {
let base_label = if let Some(m) = motifs {
m.get(letter).cloned().unwrap_or(letter.to_string())
} else {
letter.to_string()
};
let label = if brickplot.mark_primary && *letter == 'A' {
format!("{}*", base_label)
} else {
base_label
};
entries.push(LegendEntry {
label,
color: color.clone(),
shape: LegendShape::Rect,
dasharray: None,
})
}
}
Plot::Box(boxplot) => {
if let Some(label) = &boxplot.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: boxplot.color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
Plot::Violin(violin) => {
if let Some(label) = &violin.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: violin.color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
Plot::Histogram(hist) => {
if let Some(label) = &hist.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: hist.color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
Plot::Waterfall(wp) => {
if let Some(ref label) = wp.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: wp.color_positive.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
Plot::Strip(sp) => {
if sp.legend_label.is_some() {
if let Some(ref colors) = sp.group_colors {
for (i, group) in sp.groups.iter().enumerate() {
let color = colors.get(i).cloned().unwrap_or_else(|| sp.color.clone());
entries.push(LegendEntry {
label: group.label.clone(),
color,
shape: LegendShape::Circle,
dasharray: None,
});
}
} else if let Some(ref label) = sp.legend_label {
if !label.is_empty() {
entries.push(LegendEntry {
label: label.clone(),
color: sp.color.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
}
}
}
}
Plot::Raincloud(rp) => {
if rp.legend_label.is_some() {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
for (i, group) in rp.groups.iter().enumerate() {
let color = rp
.group_colors
.as_ref()
.and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| {
if rp.groups.len() > 1 {
cat10[i].to_string()
} else {
rp.color.clone()
}
});
entries.push(LegendEntry {
label: group.label.clone(),
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Lollipop(lp) => {
if let Some(ref label) = lp.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: lp.color.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
}
}
Plot::Slope(sp) => {
if sp.legend_label.is_some() {
if sp.color_by_direction {
entries.push(LegendEntry {
label: "Increase".into(),
color: sp.color_up.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
entries.push(LegendEntry {
label: "Decrease".into(),
color: sp.color_down.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
} else if let Some(ref gc) = sp.group_colors {
for (i, pt) in sp.points.iter().enumerate() {
let color = gc.get(i).cloned().unwrap_or_else(|| sp.color.clone());
entries.push(LegendEntry {
label: pt.label.clone(),
color,
shape: LegendShape::Circle,
dasharray: None,
});
}
} else {
if let Some(ref label) = sp.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: sp.color.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
}
}
}
}
Plot::Survival(sp) => {
if sp.legend_label.is_some() {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
for (i, group) in sp.groups.iter().enumerate() {
let color = group
.color
.clone()
.or_else(|| sp.group_colors.as_ref().and_then(|c| c.get(i).cloned()))
.unwrap_or_else(|| {
if sp.groups.len() > 1 {
cat10[i].to_string()
} else {
sp.color.clone()
}
});
entries.push(LegendEntry {
label: group.label.clone(),
color,
shape: LegendShape::Line,
dasharray: None,
});
}
}
}
Plot::Heatmap(heatmap) => {
if let Some(label) = &heatmap.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: "gray".into(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
Plot::Pie(pie) => {
if pie.legend_label.is_some() {
let total: f64 = pie.slices.iter().map(|s| s.value).sum();
for slice in &pie.slices {
let label = if pie.show_percent {
let pct = slice.value / total * 100.0;
if slice.label.is_empty() {
format!("{:.1}%", pct)
} else {
format!("{} ({:.1}%)", slice.label, pct)
}
} else {
slice.label.clone()
};
entries.push(LegendEntry {
label,
color: slice.color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Volcano(vp) => {
if vp.legend_label.is_some() {
entries.push(LegendEntry {
label: "Up".into(),
color: vp.color_up.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
entries.push(LegendEntry {
label: "Down".into(),
color: vp.color_down.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
entries.push(LegendEntry {
label: "NS".into(),
color: vp.color_ns.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
}
}
Plot::Manhattan(mp) => {
if mp.legend_label.is_some() {
entries.push(LegendEntry {
label: "Genome-wide".into(),
color: "#cc3333".into(),
shape: LegendShape::Line,
dasharray: Some("4 4".into()),
});
entries.push(LegendEntry {
label: "Suggestive".into(),
color: "#888888".into(),
shape: LegendShape::Line,
dasharray: Some("4 4".into()),
});
}
}
Plot::DotPlot(dp) => {
if dp.size_label.is_some() {
let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
for &pct in &[0.25_f64, 0.50, 0.75, 1.0] {
let value_at_pct = size_min + pct * (size_max - size_min);
let radius_at_pct = dp.max_radius * pct;
entries.push(LegendEntry {
label: format!("{:.1}", value_at_pct),
color: "#444444".into(),
shape: LegendShape::CircleSize(radius_at_pct),
dasharray: None,
});
}
}
}
Plot::StackedArea(sa) => {
for k in 0..sa.series.len() {
if let Some(Some(ref label)) = sa.labels.get(k) {
entries.push(LegendEntry {
label: label.clone(),
color: sa.resolve_color(k).to_string(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Candlestick(cp) => {
if let Some(ref label) = cp.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: cp.color_up.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
Plot::Chord(chord) => {
if chord.legend_label.is_some() {
use crate::render::palette::Palette;
let fallback = Palette::category10();
let n = chord.n_nodes();
for i in 0..n {
let color = if let Some(c) = chord.colors.get(i) {
if !c.is_empty() {
c.clone()
} else {
fallback[i % fallback.len()].to_string()
}
} else {
fallback[i % fallback.len()].to_string()
};
let label = if let Some(l) = chord.labels.get(i) {
l.clone()
} else {
format!("{i}")
};
entries.push(LegendEntry {
label,
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Sankey(s) => {
if s.legend_label.is_some() {
use crate::render::palette::Palette;
let fallback = Palette::category10();
for (i, node) in s.nodes.iter().enumerate() {
let color = node
.color
.clone()
.unwrap_or_else(|| fallback[i % fallback.len()].to_string());
entries.push(LegendEntry {
label: node.label.clone(),
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Network(net) => {
if net.legend_label.is_some() {
use crate::render::palette::Palette;
let fallback = Palette::category10();
let mut seen: Vec<String> = Vec::new();
let mut gi = 0usize;
for node in &net.nodes {
if let Some(ref g) = node.group {
if !seen.contains(g) {
let color = fallback[gi % fallback.len()].to_string();
entries.push(LegendEntry {
label: g.clone(),
color,
shape: LegendShape::Circle,
dasharray: None,
});
seen.push(g.clone());
gi += 1;
}
}
}
if seen.is_empty() {
for (i, node) in net.nodes.iter().enumerate() {
let color = node
.color
.clone()
.unwrap_or_else(|| fallback[i % fallback.len()].to_string());
entries.push(LegendEntry {
label: node.label.clone(),
color,
shape: LegendShape::Circle,
dasharray: None,
});
}
}
}
}
Plot::Contour(cp) => {
if let Some(ref label) = cp.legend_label {
if !cp.filled {
let line_color = cp
.line_color
.clone()
.unwrap_or_else(|| cp.color_map.map(0.5));
entries.push(LegendEntry {
label: label.clone(),
color: line_color,
shape: LegendShape::Line,
dasharray: None,
});
}
}
}
Plot::PhyloTree(t) => {
if t.legend_label.is_some() {
for (node_id, color) in &t.clade_colors {
let label = t.nodes[*node_id]
.label
.clone()
.unwrap_or_else(|| format!("Node {}", node_id));
entries.push(LegendEntry {
label,
color: color.clone(),
shape: LegendShape::Line,
dasharray: None,
});
}
}
}
Plot::Synteny(sp) => {
if sp.legend_label.is_some() {
use crate::render::palette::Palette;
let fallback = Palette::category10();
for (i, seq) in sp.sequences.iter().enumerate() {
let color = seq
.color
.clone()
.unwrap_or_else(|| fallback[i % fallback.len()].to_string());
entries.push(LegendEntry {
label: seq.label.clone(),
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Density(dp) => {
if let Some(ref label) = dp.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: dp.color.clone(),
shape: LegendShape::Line,
dasharray: dp.line_dash.clone(),
});
}
}
Plot::Ridgeline(rp) => {
if rp.show_legend {
use crate::render::palette::Palette;
let fallback = Palette::category10();
for (i, group) in rp.groups.iter().enumerate() {
let color = group
.color
.clone()
.unwrap_or_else(|| fallback[i % fallback.len()].to_string());
entries.push(LegendEntry {
label: group.label.clone(),
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Polar(pp) => {
if pp.show_legend {
use crate::render::palette::Palette;
let fallback = Palette::category10();
for (i, series) in pp.series.iter().enumerate() {
if let Some(ref label) = series.label {
let color = series
.color
.clone()
.unwrap_or_else(|| fallback[i % fallback.len()].to_string());
entries.push(LegendEntry {
label: label.clone(),
color,
shape: LegendShape::Circle,
dasharray: None,
});
}
}
}
}
Plot::Ternary(tp) => {
if tp.show_legend {
use crate::render::palette::Palette;
let fallback = Palette::category10();
let groups = tp.unique_groups();
for (i, group) in groups.iter().enumerate() {
entries.push(LegendEntry {
label: group.clone(),
color: fallback[i % fallback.len()].to_string(),
shape: LegendShape::Circle,
dasharray: None,
});
}
}
}
Plot::Scatter3D(s) => {
if let Some(ref label) = s.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: s.color.clone(),
shape: LegendShape::Marker(s.marker),
dasharray: None,
});
}
}
Plot::Surface3D(s) => {
if let Some(ref label) = s.legend_label {
entries.push(LegendEntry {
label: label.clone(),
color: s.color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
Plot::DicePlot(dp) => {
for (label, color) in &dp.dot_legend {
entries.push(LegendEntry {
label: label.clone(),
color: color.clone(),
shape: LegendShape::Circle,
dasharray: None,
});
}
}
Plot::Roc(roc) => {
if roc.legend_label.is_some() {
let cat10 = crate::render::palette::Palette::category10();
for (i, group) in roc.groups.iter().enumerate() {
let color = group.color.clone().unwrap_or_else(|| {
if roc.groups.len() == 1 {
roc.color.clone()
} else {
cat10[i % cat10.len()].to_string()
}
});
let computed_g = crate::plot::roc::compute_group(group);
let auc_str = if group.show_auc_label {
format!(" (AUC = {:.3})", computed_g.auc)
} else {
String::new()
};
entries.push(LegendEntry {
label: format!("{}{}", group.label, auc_str),
color,
shape: LegendShape::Line,
dasharray: group.dasharray.clone(),
});
}
}
}
Plot::Pr(pr) => {
if pr.legend_label.is_some() {
let cat10 = crate::render::palette::Palette::category10();
for (i, group) in pr.groups.iter().enumerate() {
let color = group.color.clone().unwrap_or_else(|| {
if pr.groups.len() == 1 {
pr.color.clone()
} else {
cat10[i % cat10.len()].to_string()
}
});
let computed_g = crate::plot::pr::compute_pr_group(group);
let auc_str = if group.show_auc_label {
format!(" (AUC-PR = {:.3})", computed_g.auc)
} else {
String::new()
};
entries.push(LegendEntry {
label: format!("{}{}", group.label, auc_str),
color,
shape: LegendShape::Line,
dasharray: group.dasharray.clone(),
});
}
}
}
Plot::Joint(jp) => {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
for (gi, group) in jp.groups.iter().enumerate() {
if let Some(ref lbl) = group.scatter.legend_label {
let color =
if group.scatter.color == "black" && group.scatter.colors.is_none() {
cat10[gi % cat10.len()].to_string()
} else {
group.scatter.color.clone()
};
entries.push(LegendEntry {
label: lbl.clone(),
color,
shape: LegendShape::Marker(group.scatter.marker),
dasharray: None,
});
}
}
}
Plot::Venn(vp) => {
if vp.legend_label.is_some() {
use crate::render::palette::Palette;
let _pal = Palette::category10();
for (i, set) in vp.sets.iter().enumerate() {
let color = vp.color_for(i);
entries.push(LegendEntry {
label: set.label.clone(),
color,
shape: LegendShape::Circle,
dasharray: None,
});
}
}
}
Plot::Parallel(pp) => {
if pp.legend_label.is_some() {
let groups = pp.groups();
if groups.is_empty() {
entries.push(LegendEntry {
label: pp.color.clone(),
color: pp.color.clone(),
shape: LegendShape::Line,
dasharray: None,
});
} else {
for (i, g) in groups.iter().enumerate() {
entries.push(LegendEntry {
label: g.clone(),
color: pp.color_for_group_idx(i),
shape: LegendShape::Line,
dasharray: None,
});
}
}
}
}
Plot::Mosaic(mp) => {
if mp.legend_label.is_some() {
let rows = mp.effective_row_order();
for (i, row) in rows.iter().enumerate() {
entries.push(LegendEntry {
label: row.clone(),
color: mp.color_for_row_idx(i),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Ecdf(ep) => {
if ep.legend_label.is_some() {
let cat10 = crate::render::palette::Palette::category10();
for (i, group) in ep.groups.iter().enumerate() {
let color = group.color.clone().unwrap_or_else(|| {
if ep.groups.len() == 1 {
ep.color.clone()
} else {
cat10[i % cat10.len()].to_string()
}
});
entries.push(LegendEntry {
label: group.label.clone(),
color,
shape: LegendShape::Line,
dasharray: ep.line_dash.clone(),
});
}
}
}
Plot::QQ(qp) => {
if qp.legend_label.is_some() {
let cat10 = crate::render::palette::Palette::category10();
for (i, group) in qp.groups.iter().enumerate() {
let color = group.color.clone().unwrap_or_else(|| {
if qp.groups.len() == 1 {
qp.color.clone()
} else {
cat10[i % cat10.len()].to_string()
}
});
entries.push(LegendEntry {
label: group.label.clone(),
color,
shape: LegendShape::Circle,
dasharray: None,
});
}
}
}
Plot::Streamgraph(sg) => {
if sg.legend_label.is_some() {
for k in 0..sg.series.len() {
if let Some(Some(ref label)) = sg.labels.get(k) {
entries.push(LegendEntry {
label: label.clone(),
color: sg.resolve_color(k).to_string(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
}
Plot::Radar(rp) => {
if rp.show_legend {
use crate::render::palette::Palette;
let pal = Palette::category10();
for (i, s) in rp.series.iter().enumerate() {
if let Some(ref lbl) = s.label {
let color = s.color.clone().unwrap_or_else(|| pal[i].to_string());
entries.push(LegendEntry {
label: lbl.clone(),
color,
shape: LegendShape::Line,
dasharray: s.dasharray.clone(),
});
}
}
for ref_poly in &rp.references {
if let Some(ref lbl) = ref_poly.label {
let color = ref_poly
.color
.clone()
.unwrap_or_else(|| "#999999".to_string());
entries.push(LegendEntry {
label: lbl.clone(),
color,
shape: LegendShape::Line,
dasharray: Some("6,3".to_string()),
});
}
}
}
}
Plot::Bump(bp) => {
if bp.legend {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
let series = bp.resolved_series();
for (i, s) in series.iter().enumerate() {
let color = s.color.clone().unwrap_or_else(|| cat10[i].to_string());
entries.push(LegendEntry {
label: s.name.clone(),
color,
shape: LegendShape::Line,
dasharray: None,
});
}
}
}
Plot::Funnel(fp) => {
if fp.legend_label.is_some() {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
let all_stages = {
let mut v: Vec<(usize, &FunnelStage)> =
fp.stages.iter().enumerate().collect();
if let Some(ref mir) = fp.mirror {
for (i, s) in mir.iter().enumerate() {
if !v.iter().any(|(_, ls)| ls.label == s.label) {
v.push((i, s));
}
}
}
v
};
for (i, s) in all_stages {
let color = s.color.clone().unwrap_or_else(|| match fp.color_mode {
FunnelColorMode::Uniform => cat10[0].to_string(),
FunnelColorMode::ByStage | FunnelColorMode::Gradient => {
cat10[i % 10].to_string()
}
});
entries.push(LegendEntry {
label: s.label.clone(),
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Rose(rp) => {
if rp.legend_label.is_some() {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
for (i, s) in rp.series.iter().enumerate() {
let color = s.color.clone().unwrap_or_else(|| cat10[i % 10].to_string());
entries.push(LegendEntry {
label: s.name.clone(),
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Pyramid(pp) => {
if pp.show_legend {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
if pp.series.len() <= 1 {
let left_color = pp
.series
.first()
.and_then(|s| s.color.clone())
.unwrap_or_else(|| pp.left_color.clone());
let right_color = pp
.series
.first()
.and_then(|s| s.color.clone())
.unwrap_or_else(|| pp.right_color.clone());
entries.push(LegendEntry {
label: pp.left_label.clone(),
color: left_color,
shape: LegendShape::Rect,
dasharray: None,
});
entries.push(LegendEntry {
label: pp.right_label.clone(),
color: right_color,
shape: LegendShape::Rect,
dasharray: None,
});
} else {
for (i, s) in pp.series.iter().enumerate() {
let color = s
.color
.clone()
.unwrap_or_else(|| cat10[i % cat10.len()].to_string());
entries.push(LegendEntry {
label: s.label.clone(),
color,
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
}
Plot::Waffle(wp) => {
if wp.legend_label.is_some() {
let total_val: f64 = wp.categories.iter().map(|c| c.value).sum();
let n_cells = wp.rows * wp.cols;
let cell_counts = waffle_largest_remainder(
&wp.categories.iter().map(|c| c.value).collect::<Vec<_>>(),
n_cells,
);
for (i, cat) in wp.categories.iter().enumerate() {
let label = waffle_legend_label(cat, i, total_val, &cell_counts, wp);
entries.push(LegendEntry {
label,
color: cat.color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Horizon(hp) => {
if hp.show_legend {
for s in &hp.series {
entries.push(LegendEntry {
label: s.label.clone(),
color: s.pos_color.clone(),
shape: LegendShape::Rect,
dasharray: None,
});
}
}
}
Plot::Text(_) => {}
Plot::LegendPlot(_) => {}
_ => {}
}
}
entries
}
#[allow(clippy::too_many_arguments)]
pub fn render_legend_at(
entries: &[LegendEntry],
groups: Option<&[LegendGroup]>,
title: Option<&str>,
show_box: bool,
scene: &mut Scene,
x: f64,
y: f64,
width: f64,
body_size: u32,
theme: &Theme,
) {
let legend_padding = 10.0;
let line_height = 18.0;
let entry_rows = if let Some(groups) = groups {
groups.iter().map(|g| g.entries.len() + 1).sum::<usize>()
} else {
entries.len()
};
let title_rows = if title.is_some() { 1 } else { 0 };
let legend_height = (entry_rows + title_rows) as f64 * line_height + legend_padding * 2.0;
if show_box {
scene.add(Primitive::Rect {
x: x - legend_padding + 5.0,
y: y - legend_padding,
width,
height: legend_height,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: x - legend_padding + 5.0,
y: y - legend_padding,
width,
height: legend_height,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(1.0),
opacity: None,
});
}
let mut cur_y = y;
if let Some(t) = title {
scene.add(Primitive::Text {
x: x + width / 2.0,
y: cur_y + 5.0,
content: t.to_string(),
anchor: TextAnchor::Middle,
size: body_size,
rotate: None,
bold: true,
color: None,
});
cur_y += line_height;
}
let render_entry = |entry: &LegendEntry, scene: &mut Scene, cur_y: f64| {
let swatch_cy = cur_y + 5.0;
let text_baseline = swatch_cy + body_size as f64 * 0.35;
scene.add(Primitive::Text {
x: x + 25.0,
y: text_baseline,
content: entry.label.clone(),
anchor: TextAnchor::Start,
size: body_size,
rotate: None,
bold: false,
color: None,
});
match entry.shape {
LegendShape::Rect => scene.add(Primitive::Rect {
x: x + 5.0,
y: cur_y - 1.0,
width: 12.0,
height: 12.0,
fill: Color::from(&entry.color),
stroke: None,
stroke_width: None,
opacity: None,
}),
LegendShape::Line => scene.add(Primitive::Line {
x1: x + 5.0,
y1: swatch_cy,
x2: x + 5.0 + 12.0,
y2: swatch_cy,
stroke: Color::from(&entry.color),
stroke_width: 2.0,
stroke_dasharray: entry.dasharray.clone(),
}),
LegendShape::Circle => scene.add(Primitive::Circle {
cx: x + 5.0 + 6.0,
cy: swatch_cy,
r: 5.0,
fill: Color::from(&entry.color),
fill_opacity: None,
stroke: None,
stroke_width: None,
}),
LegendShape::Marker(marker) => {
draw_marker(
scene,
marker,
x + 5.0 + 6.0,
swatch_cy,
5.0,
&entry.color,
None,
None,
None,
);
}
LegendShape::CircleSize(r) => {
let swatch_half = 8.0;
let draw_r = r.min(swatch_half);
scene.add(Primitive::Circle {
cx: x + 5.0 + 6.0,
cy: swatch_cy,
r: draw_r,
fill: Color::from(&entry.color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
};
if let Some(groups) = groups {
for group in groups {
scene.add(Primitive::Text {
x: x + 5.0,
y: cur_y + 5.0,
content: group.title.clone(),
anchor: TextAnchor::Start,
size: body_size,
rotate: None,
bold: true,
color: None,
});
cur_y += line_height;
for entry in &group.entries {
render_entry(entry, scene, cur_y);
cur_y += line_height;
}
}
} else {
for entry in entries {
render_entry(entry, scene, cur_y);
cur_y += line_height;
}
}
}
fn add_upset(up: &UpSetPlot, scene: &mut Scene, computed: &ComputedLayout) {
if up.set_names.is_empty() {
return;
}
let sorted = up.sorted_intersections();
if sorted.is_empty() {
return;
}
let n_sets = up.set_names.len();
let n_cols = sorted.len();
let theme = &computed.theme;
let pl = computed.margin_left;
let pr = computed.width - computed.margin_right;
let pt = computed.margin_top;
let pb = computed.height - computed.margin_bottom;
let pw = pr - pl;
let ph = pb - pt;
let tick_size = computed.tick_size as f64;
let label_size = computed.label_size as f64;
let max_name_len = up.set_names.iter().map(|n| n.len()).max().unwrap_or(0);
let name_area = (max_name_len as f64 * tick_size * 0.6 + 10.0).clamp(40.0, 120.0);
let bar_area = if up.show_set_sizes {
(pw * 0.18).clamp(50.0, 150.0)
} else {
0.0
};
let count_gap = if up.show_counts && up.show_set_sizes {
28.0
} else {
0.0
};
let left_panel_w = bar_area + count_gap + name_area;
let inter_bar_h = ph * 0.55;
let mat_l = pl + left_panel_w;
let mat_t = pt + inter_bar_h;
let mat_r = pr;
let mat_b = pb;
let dot_col_w = if n_cols > 0 {
(mat_r - mat_l) / n_cols as f64
} else {
1.0
};
let dot_row_h = if n_sets > 0 {
(mat_b - mat_t) / n_sets as f64
} else {
1.0
};
let dot_r = (dot_col_w.min(dot_row_h) * 0.35).clamp(3.0, 12.0);
let bar_half_w = (dot_col_w * 0.3).max(3.0);
let max_inter = sorted.iter().map(|i| i.count).max().unwrap_or(1) as f64;
let max_set = up.set_sizes.iter().copied().max().unwrap_or(1) as f64;
if up.show_set_sizes {
let bar_x_start = pl;
let bar_x_end = pl + bar_area;
let bar_half_h = (dot_row_h * 0.25).clamp(3.0, 12.0);
scene.add(Primitive::Line {
x1: bar_x_end,
y1: mat_t,
x2: bar_x_end,
y2: mat_b,
stroke: Color::from(&theme.axis_color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: bar_x_start,
y1: mat_b,
x2: bar_x_end,
y2: mat_b,
stroke: Color::from(&theme.axis_color),
stroke_width: 1.0,
stroke_dasharray: None,
});
for (j, &size) in up.set_sizes.iter().enumerate() {
let cy = mat_t + (j as f64 + 0.5) * dot_row_h;
let bar_w = size as f64 / max_set * bar_area;
scene.add(Primitive::Rect {
x: bar_x_end - bar_w,
y: cy - bar_half_h,
width: bar_w,
height: bar_half_h * 2.0,
fill: Color::from(&up.bar_color),
stroke: None,
stroke_width: None,
opacity: None,
});
if up.show_counts {
scene.add(Primitive::Text {
x: pl + bar_area + 3.0,
y: cy + tick_size * 0.35,
content: format!("{}", size),
size: computed.tick_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
scene.add(Primitive::Text {
x: bar_x_start + bar_area / 2.0,
y: mat_t - tick_size - 4.0,
content: "Set size".to_string(),
size: computed.label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
let name_x = mat_l - 5.0; for (j, name) in up.set_names.iter().enumerate() {
let cy = mat_t + (j as f64 + 0.5) * dot_row_h;
scene.add(Primitive::Text {
x: name_x,
y: cy + tick_size * 0.35,
content: name.clone(),
size: computed.tick_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
let bar_y_max = pt + inter_bar_h - 5.0; let bar_y_min = pt + tick_size + 2.0; let bar_h_range = (bar_y_max - bar_y_min).max(1.0);
scene.add(Primitive::Line {
x1: mat_l,
y1: bar_y_min,
x2: mat_l,
y2: bar_y_max,
stroke: Color::from(&theme.axis_color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Line {
x1: mat_l,
y1: bar_y_max,
x2: mat_r,
y2: bar_y_max,
stroke: Color::from(&theme.axis_color),
stroke_width: 1.0,
stroke_dasharray: None,
});
let n_yticks = 4;
for ti in 0..=n_yticks {
let frac = ti as f64 / n_yticks as f64;
let val = (max_inter * frac).round() as usize;
let y = bar_y_max - frac * bar_h_range;
scene.add(Primitive::Line {
x1: mat_l - 4.0,
y1: y,
x2: mat_l,
y2: y,
stroke: Color::from(&theme.tick_color),
stroke_width: 1.0,
stroke_dasharray: None,
});
scene.add(Primitive::Text {
x: mat_l - 7.0,
y: y + tick_size * 0.35,
content: format!("{}", val),
size: computed.tick_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
scene.add(Primitive::Text {
x: mat_l - 7.0 - tick_size * 2.5 - label_size * 0.5,
y: (bar_y_min + bar_y_max) / 2.0,
content: "Intersection size".to_string(),
size: computed.label_size,
anchor: TextAnchor::Middle,
rotate: Some(-90.0),
bold: false,
color: None,
});
for (i, inter) in sorted.iter().enumerate() {
let cx = mat_l + (i as f64 + 0.5) * dot_col_w;
let bar_h = (inter.count as f64 / max_inter * bar_h_range).max(0.0);
let bar_x = cx - bar_half_w;
let bar_y = bar_y_max - bar_h;
scene.add(Primitive::Rect {
x: bar_x,
y: bar_y,
width: bar_half_w * 2.0,
height: bar_h,
fill: Color::from(&up.bar_color),
stroke: None,
stroke_width: None,
opacity: None,
});
let min_col_for_label = computed.tick_size as f64 * 1.5;
if up.show_counts && bar_h > 0.0 && dot_col_w >= min_col_for_label {
scene.add(Primitive::Text {
x: cx,
y: bar_y - 2.0,
content: format!("{}", inter.count),
size: computed.tick_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
for j in 0..=n_sets {
let y = mat_t + j as f64 * dot_row_h;
scene.add(Primitive::Line {
x1: mat_l,
y1: y,
x2: mat_r,
y2: y,
stroke: Color::from(&theme.grid_color),
stroke_width: 0.5,
stroke_dasharray: None,
});
}
for (i, inter) in sorted.iter().enumerate() {
let cx = mat_l + (i as f64 + 0.5) * dot_col_w;
let filled_rows: Vec<usize> = (0..n_sets)
.filter(|&j| inter.mask & (1u64 << j) != 0)
.collect();
if filled_rows.len() >= 2 {
let top_j = *filled_rows
.first()
.expect("filled_rows.len() >= 2 guarantees first");
let bot_j = *filled_rows
.last()
.expect("filled_rows.len() >= 2 guarantees last");
let top_cy = mat_t + (top_j as f64 + 0.5) * dot_row_h;
let bot_cy = mat_t + (bot_j as f64 + 0.5) * dot_row_h;
scene.add(Primitive::Line {
x1: cx,
y1: top_cy,
x2: cx,
y2: bot_cy,
stroke: Color::from(&up.dot_color),
stroke_width: (dot_r * 0.5).max(2.0),
stroke_dasharray: None,
});
}
for j in 0..n_sets {
let cy = mat_t + (j as f64 + 0.5) * dot_row_h;
let filled = inter.mask & (1u64 << j) != 0;
let fill = if filled {
up.dot_color.clone()
} else {
up.dot_empty_color.clone()
};
scene.add(Primitive::Circle {
cx,
cy,
r: dot_r,
fill: fill.into(),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
}
fn add_stacked_area(sa: &StackedAreaPlot, scene: &mut Scene, computed: &ComputedLayout) {
if sa.x.is_empty() || sa.series.is_empty() {
return;
}
let n = sa.x.len();
let totals: Vec<f64> = if sa.normalized {
(0..n)
.map(|i| {
sa.series
.iter()
.map(|s| s.get(i).copied().unwrap_or(0.0))
.sum::<f64>()
})
.collect()
} else {
vec![1.0; n]
};
let scale = if sa.normalized { 100.0 } else { 1.0 };
let mut lower: Vec<f64> = vec![0.0; n];
for k in 0..sa.series.len() {
let series = &sa.series[k];
let color = sa.resolve_color(k).to_string();
let upper: Vec<f64> = (0..n)
.map(|i| {
let raw = series.get(i).copied().unwrap_or(0.0);
let t = totals[i].max(f64::EPSILON);
lower[i] + raw / t * scale
})
.collect();
let mut path = String::with_capacity(n * 32);
{
let mut rb = ryu::Buffer::new();
for (i, &x) in sa.x.iter().enumerate() {
let sx = computed.map_x(x);
let sy = computed.map_y(upper[i]);
path.push(if i == 0 { 'M' } else { 'L' });
path.push(' ');
path.push_str(rb.format(round2(sx)));
path.push(' ');
path.push_str(rb.format(round2(sy)));
path.push(' ');
}
for i in (0..n).rev() {
let sx = computed.map_x(sa.x[i]);
let sy = computed.map_y(lower[i]);
path.push_str("L ");
path.push_str(rb.format(round2(sx)));
path.push(' ');
path.push_str(rb.format(round2(sy)));
path.push(' ');
}
}
path.push('Z');
scene.add(Primitive::Path(Box::new(PathData {
d: path,
fill: Some(Color::from(&color)),
stroke: "none".into(),
stroke_width: 0.0,
opacity: Some(sa.fill_opacity),
stroke_dasharray: None,
})));
if sa.show_strokes {
let mut stroke_path = String::with_capacity(n * 16);
let mut rb = ryu::Buffer::new();
for (i, &x) in sa.x.iter().enumerate() {
let sx = computed.map_x(x);
let sy = computed.map_y(upper[i]);
stroke_path.push(if i == 0 { 'M' } else { 'L' });
stroke_path.push(' ');
stroke_path.push_str(rb.format(round2(sx)));
stroke_path.push(' ');
stroke_path.push_str(rb.format(round2(sy)));
stroke_path.push(' ');
}
scene.add(Primitive::Path(Box::new(PathData {
d: stroke_path,
fill: None,
stroke: color.into(),
stroke_width: sa.stroke_width,
opacity: None,
stroke_dasharray: None,
})));
}
lower[..n].copy_from_slice(&upper[..n]);
}
}
fn stream_band_path(upper_px: &[(f64, f64)], lower_px: &[(f64, f64)]) -> String {
let n = upper_px.len();
if n == 0 {
return String::new();
}
let mut rb = ryu::Buffer::new();
let mut path = String::with_capacity(n * 60);
let append_cr = |path: &mut String,
p0: (f64, f64),
p1: (f64, f64),
p2: (f64, f64),
p3: (f64, f64),
rb: &mut ryu::Buffer| {
let cp1x = p1.0 + (p2.0 - p0.0) / 6.0;
let cp1y = p1.1 + (p2.1 - p0.1) / 6.0;
let cp2x = p2.0 - (p3.0 - p1.0) / 6.0;
let cp2y = p2.1 - (p3.1 - p1.1) / 6.0;
path.push_str("C ");
path.push_str(rb.format(round2(cp1x)));
path.push(' ');
path.push_str(rb.format(round2(cp1y)));
path.push(' ');
path.push_str(rb.format(round2(cp2x)));
path.push(' ');
path.push_str(rb.format(round2(cp2y)));
path.push(' ');
path.push_str(rb.format(round2(p2.0)));
path.push(' ');
path.push_str(rb.format(round2(p2.1)));
path.push(' ');
};
path.push_str("M ");
path.push_str(rb.format(round2(upper_px[0].0)));
path.push(' ');
path.push_str(rb.format(round2(upper_px[0].1)));
path.push(' ');
if n == 1 {
path.push('Z');
return path;
}
for i in 0..n - 1 {
let p0 = if i == 0 { upper_px[0] } else { upper_px[i - 1] };
let p1 = upper_px[i];
let p2 = upper_px[i + 1];
let p3 = if i + 2 < n {
upper_px[i + 2]
} else {
upper_px[n - 1]
};
append_cr(&mut path, p0, p1, p2, p3, &mut rb);
}
path.push_str("L ");
path.push_str(rb.format(round2(lower_px[n - 1].0)));
path.push(' ');
path.push_str(rb.format(round2(lower_px[n - 1].1)));
path.push(' ');
for i in (0..n - 1).rev() {
let p0 = if i + 2 < n {
lower_px[i + 2]
} else {
lower_px[n - 1]
};
let p1 = lower_px[i + 1];
let p2 = lower_px[i];
let p3 = if i == 0 { lower_px[0] } else { lower_px[i - 1] };
append_cr(&mut path, p0, p1, p2, p3, &mut rb);
}
path.push('Z');
path
}
fn stream_stroke_path(pts: &[(f64, f64)]) -> String {
let n = pts.len();
if n == 0 {
return String::new();
}
let mut rb = ryu::Buffer::new();
let mut path = String::with_capacity(n * 30);
path.push_str("M ");
path.push_str(rb.format(round2(pts[0].0)));
path.push(' ');
path.push_str(rb.format(round2(pts[0].1)));
path.push(' ');
for i in 0..n - 1 {
let p0 = if i == 0 { pts[0] } else { pts[i - 1] };
let p1 = pts[i];
let p2 = pts[i + 1];
let p3 = if i + 2 < n { pts[i + 2] } else { pts[n - 1] };
let cp1x = p1.0 + (p2.0 - p0.0) / 6.0;
let cp1y = p1.1 + (p2.1 - p0.1) / 6.0;
let cp2x = p2.0 - (p3.0 - p1.0) / 6.0;
let cp2y = p2.1 - (p3.1 - p1.1) / 6.0;
path.push_str("C ");
path.push_str(rb.format(round2(cp1x)));
path.push(' ');
path.push_str(rb.format(round2(cp1y)));
path.push(' ');
path.push_str(rb.format(round2(cp2x)));
path.push(' ');
path.push_str(rb.format(round2(cp2y)));
path.push(' ');
path.push_str(rb.format(round2(p2.0)));
path.push(' ');
path.push_str(rb.format(round2(p2.1)));
path.push(' ');
}
path
}
fn add_streamgraph(
sg: &crate::plot::streamgraph::StreamgraphPlot,
scene: &mut Scene,
computed: &ComputedLayout,
) {
let geom = match sg.compute_geometry() {
Some(g) => g,
None => return,
};
let n_pts = sg.x.len();
let n_streams = geom.render_order.len();
for k in 0..n_streams {
let orig_idx = geom.render_order[k];
let color = sg.resolve_color(orig_idx).to_string();
let upper_px: Vec<(f64, f64)> = (0..n_pts)
.map(|i| (computed.map_x(sg.x[i]), computed.map_y(geom.uppers[k][i])))
.collect();
let lower_px: Vec<(f64, f64)> = (0..n_pts)
.map(|i| (computed.map_x(sg.x[i]), computed.map_y(geom.lowers[k][i])))
.collect();
let path_d = if sg.smooth {
stream_band_path(&upper_px, &lower_px)
} else {
let mut d = String::with_capacity(n_pts * 32);
let mut rb = ryu::Buffer::new();
for (i, &(px, py)) in upper_px.iter().enumerate() {
d.push(if i == 0 { 'M' } else { 'L' });
d.push(' ');
d.push_str(rb.format(round2(px)));
d.push(' ');
d.push_str(rb.format(round2(py)));
d.push(' ');
}
for &(px, py) in lower_px.iter().rev() {
d.push_str("L ");
d.push_str(rb.format(round2(px)));
d.push(' ');
d.push_str(rb.format(round2(py)));
d.push(' ');
}
d.push('Z');
d
};
scene.add(Primitive::Path(Box::new(PathData {
d: path_d,
fill: Some(Color::from(&color)),
stroke: "none".into(),
stroke_width: 0.0,
opacity: Some(sg.fill_opacity),
stroke_dasharray: None,
})));
if sg.stroke_between {
let stroke_d = if sg.smooth {
stream_stroke_path(&upper_px)
} else {
let mut d = String::with_capacity(n_pts * 20);
let mut rb = ryu::Buffer::new();
for (i, &(px, py)) in upper_px.iter().enumerate() {
d.push(if i == 0 { 'M' } else { 'L' });
d.push(' ');
d.push_str(rb.format(round2(px)));
d.push(' ');
d.push_str(rb.format(round2(py)));
d.push(' ');
}
d
};
scene.add(Primitive::Path(Box::new(PathData {
d: stroke_d,
fill: None,
stroke: "white".into(),
stroke_width: sg.stroke_width,
opacity: None,
stroke_dasharray: None,
})));
}
}
if sg.show_labels {
for k in 0..n_streams {
let orig_idx = geom.render_order[k];
let label = match sg.labels.get(orig_idx).and_then(|l| l.as_ref()) {
Some(l) => l.clone(),
None => continue,
};
let font_size = computed.body_size as f64;
let half_text_w = label.len() as f64 * font_size * 0.60 / 2.0 + 4.0;
let plot_left_px = computed.margin_left;
let plot_right_px = computed.width - computed.margin_right;
let min_h = (font_size * 1.3 + 4.0).max(sg.min_label_height);
let inner_lo = (n_pts as f64 * 0.10).ceil() as usize;
let inner_hi = (n_pts as f64 * 0.90).floor() as usize;
let search_start = inner_lo.max(if n_pts > 2 { 1 } else { 0 });
let search_end = inner_hi.min(if n_pts > 2 { n_pts - 1 } else { n_pts });
let max_idx_opt = (search_start..search_end).max_by(|&a, &b| {
let ha = geom.uppers[k][a] - geom.lowers[k][a];
let hb = geom.uppers[k][b] - geom.lowers[k][b];
ha.partial_cmp(&hb).unwrap_or(std::cmp::Ordering::Equal)
});
let max_idx = match max_idx_opt {
Some(i) => i,
None => continue,
};
let lower_y = computed.map_y(geom.lowers[k][max_idx]);
let upper_y = computed.map_y(geom.uppers[k][max_idx]);
let height_px = (lower_y - upper_y).abs();
if height_px < min_h {
continue;
}
let raw_x = computed.map_x(sg.x[max_idx]);
let mid_x = raw_x
.max(plot_left_px + half_text_w)
.min(plot_right_px - half_text_w);
let mid_y = (lower_y + upper_y) / 2.0 + font_size * 0.35;
let txt_color = choose_label_color(sg.resolve_color(orig_idx));
scene.add(Primitive::Text {
x: mid_x,
y: mid_y,
content: label,
size: computed.body_size,
color: Some(Color::from(txt_color)),
anchor: TextAnchor::Middle,
bold: false,
rotate: None,
});
}
}
}
fn choose_label_color(css: &str) -> &'static str {
let dark_fills = [
"steelblue",
"cornflowerblue",
"mediumpurple",
"orchid",
"peru",
"tomato",
"coral",
"goldenrod",
"mediumseagreen",
"lightslategray",
];
if dark_fills.contains(&css) {
"white"
} else if css.starts_with('#') && css.len() == 7 {
let r = u8::from_str_radix(&css[1..3], 16).unwrap_or(128) as f64;
let g = u8::from_str_radix(&css[3..5], 16).unwrap_or(128) as f64;
let b = u8::from_str_radix(&css[5..7], 16).unwrap_or(128) as f64;
let lum = 0.299 * r + 0.587 * g + 0.114 * b;
if lum < 140.0 {
"white"
} else {
"#333333"
}
} else {
"white"
}
}
fn add_candlestick(cp: &CandlestickPlot, scene: &mut Scene, computed: &ComputedLayout) {
if cp.candles.is_empty() {
return;
}
let continuous = cp.candles.iter().any(|c| c.x.is_some());
let n = cp.candles.len();
let slot_px = if continuous {
if n > 1 {
let xs: Vec<f64> = cp.candles.iter().filter_map(|c| c.x).collect();
if xs.len() > 1 {
let span = xs[xs.len() - 1] - xs[0];
let avg_spacing = span / (xs.len() - 1) as f64;
computed.map_x(computed.x_range.0 + avg_spacing)
- computed.map_x(computed.x_range.0)
} else {
computed.plot_width()
}
} else {
computed.plot_width()
}
} else {
computed.map_x(1.5) - computed.map_x(0.5)
};
let body_w = slot_px * cp.candle_width;
let price_bottom_px = if cp.show_volume {
computed.margin_top + computed.plot_height() * (1.0 - cp.volume_ratio) - 4.0
} else {
computed.margin_top + computed.plot_height()
};
let y_min = computed.y_range.0;
let y_max = computed.y_range.1;
let map_y_price = |v: f64| -> f64 {
let t = (y_max - v) / (y_max - y_min);
computed.margin_top + t * (price_bottom_px - computed.margin_top)
};
let candle_color = |c: &CandleDataPoint| -> &str {
if c.close > c.open {
&cp.color_up
} else if c.close < c.open {
&cp.color_down
} else {
&cp.color_doji
}
};
for (i, candle) in cp.candles.iter().enumerate() {
let x_val = if continuous {
candle.x.unwrap_or(i as f64 + 1.0)
} else {
i as f64 + 1.0
};
let x_center = computed.map_x(x_val);
let color = candle_color(candle).to_string();
let tip = tooltip(cp.show_tooltips, &cp.tooltip_labels, i, || {
format!(
"{}\nO:{:.2} H:{:.2} L:{:.2} C:{:.2}",
candle.label, candle.open, candle.high, candle.low, candle.close
)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Line {
x1: x_center,
y1: map_y_price(candle.high),
x2: x_center,
y2: map_y_price(candle.low),
stroke: Color::from(&color),
stroke_width: cp.wick_width,
stroke_dasharray: None,
});
let body_top = map_y_price(candle.open.max(candle.close));
let body_bottom = map_y_price(candle.open.min(candle.close));
let body_h = (body_bottom - body_top).max(1.0);
scene.add(Primitive::Rect {
x: x_center - body_w / 2.0,
y: body_top,
width: body_w,
height: body_h,
fill: Color::from(&color),
stroke: Some(Color::from(&color)),
stroke_width: Some(0.5),
opacity: None,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
if cp.show_volume {
let vol_panel_top = price_bottom_px + 4.0;
let vol_panel_bottom = computed.margin_top + computed.plot_height() - 2.0;
let vol_panel_h = vol_panel_bottom - vol_panel_top;
let vol_max = cp
.candles
.iter()
.filter_map(|c| c.volume)
.fold(0.0_f64, f64::max);
if vol_max > 0.0 {
for (i, candle) in cp.candles.iter().enumerate() {
if let Some(vol) = candle.volume {
let x_val = if continuous {
candle.x.unwrap_or(i as f64 + 1.0)
} else {
i as f64 + 1.0
};
let x_center = computed.map_x(x_val);
let color = candle_color(candle).to_string();
let bar_h = (vol / vol_max) * vol_panel_h;
scene.add(Primitive::Rect {
x: x_center - body_w / 2.0,
y: vol_panel_bottom - bar_h,
width: body_w,
height: bar_h,
fill: color.into(),
stroke: None,
stroke_width: None,
opacity: Some(0.5),
});
}
}
}
}
}
fn contour_path(
z: &[Vec<f64>],
x_coords: &[f64],
y_coords: &[f64],
t: f64,
computed: &ComputedLayout,
) -> String {
let rows = z.len();
if rows < 2 {
return String::new();
}
let cols = z[0].len();
if cols < 2 {
return String::new();
}
let mut d = String::new();
let h = |col: usize, row: usize| -> (f64, f64) {
let va = z[row][col];
let vb = z[row][col + 1];
let frac = if (vb - va).abs() < 1e-12 {
0.5
} else {
((t - va) / (vb - va)).clamp(0.0, 1.0)
};
let wx = x_coords[col] + frac * (x_coords[col + 1] - x_coords[col]);
(computed.map_x(wx), computed.map_y(y_coords[row]))
};
let v = |col: usize, row: usize| -> (f64, f64) {
let va = z[row][col];
let vb = z[row + 1][col];
let frac = if (vb - va).abs() < 1e-12 {
0.5
} else {
((t - va) / (vb - va)).clamp(0.0, 1.0)
};
let wy = y_coords[row] + frac * (y_coords[row + 1] - y_coords[row]);
(computed.map_x(x_coords[col]), computed.map_y(wy))
};
let mut seg = |p1: (f64, f64), p2: (f64, f64)| {
let _ = write!(d, "M{:.2} {:.2} L{:.2} {:.2} ", p1.0, p1.1, p2.0, p2.1);
};
for row in 0..rows - 1 {
for col in 0..cols - 1 {
let tl = z[row][col];
let tr = z[row][col + 1];
let br = z[row + 1][col + 1];
let bl = z[row + 1][col];
let case = ((tl >= t) as u8) * 8
+ ((tr >= t) as u8) * 4
+ ((br >= t) as u8) * 2
+ ((bl >= t) as u8);
let avg = (tl + tr + br + bl) / 4.0;
match case {
0 | 15 => {}
1 => seg(v(col, row), h(col, row + 1)), 2 => seg(h(col, row + 1), v(col + 1, row)), 3 => seg(v(col, row), v(col + 1, row)), 4 => seg(h(col, row), v(col + 1, row)), 5 => {
if avg >= t {
seg(h(col, row), v(col, row)); seg(h(col, row + 1), v(col + 1, row)); } else {
seg(h(col, row), v(col + 1, row)); seg(h(col, row + 1), v(col, row)); }
}
6 => seg(h(col, row), h(col, row + 1)), 7 => seg(h(col, row), v(col, row)), 8 => seg(h(col, row), v(col, row)), 9 => seg(h(col, row), h(col, row + 1)), 10 => {
if avg >= t {
seg(h(col, row), v(col + 1, row)); seg(h(col, row + 1), v(col, row)); } else {
seg(h(col, row), v(col, row)); seg(h(col, row + 1), v(col + 1, row)); }
}
11 => seg(h(col, row), v(col + 1, row)), 12 => seg(v(col, row), v(col + 1, row)), 13 => seg(v(col + 1, row), h(col, row + 1)), 14 => seg(v(col, row), h(col, row + 1)), _ => {}
}
}
}
d
}
#[allow(non_snake_case)]
fn contour_fill_path(
z: &[Vec<f64>],
x_coords: &[f64],
y_coords: &[f64],
t: f64,
computed: &ComputedLayout,
) -> String {
let rows = z.len();
if rows < 2 {
return String::new();
}
let cols = z[0].len();
if cols < 2 {
return String::new();
}
let mut d = String::new();
let h = |col: usize, row: usize| -> (f64, f64) {
let va = z[row][col];
let vb = z[row][col + 1];
let frac = if (vb - va).abs() < 1e-12 {
0.5
} else {
((t - va) / (vb - va)).clamp(0.0, 1.0)
};
let wx = x_coords[col] + frac * (x_coords[col + 1] - x_coords[col]);
(computed.map_x(wx), computed.map_y(y_coords[row]))
};
let v = |col: usize, row: usize| -> (f64, f64) {
let va = z[row][col];
let vb = z[row + 1][col];
let frac = if (vb - va).abs() < 1e-12 {
0.5
} else {
((t - va) / (vb - va)).clamp(0.0, 1.0)
};
let wy = y_coords[row] + frac * (y_coords[row + 1] - y_coords[row]);
(computed.map_x(x_coords[col]), computed.map_y(wy))
};
let mut poly = |verts: &[(f64, f64)]| {
if verts.len() < 3 {
return;
}
let _ = write!(d, "M{:.2} {:.2}", verts[0].0, verts[0].1);
for &(x, y) in &verts[1..] {
let _ = write!(d, " L{:.2} {:.2}", x, y);
}
d.push_str(" Z ");
};
for row in 0..rows - 1 {
for col in 0..cols - 1 {
let tl = z[row][col];
let tr = z[row][col + 1];
let br = z[row + 1][col + 1];
let bl = z[row + 1][col];
let case = ((tl >= t) as u8) * 8
+ ((tr >= t) as u8) * 4
+ ((br >= t) as u8) * 2
+ ((bl >= t) as u8);
let tl_p = (computed.map_x(x_coords[col]), computed.map_y(y_coords[row]));
let tr_p = (
computed.map_x(x_coords[col + 1]),
computed.map_y(y_coords[row]),
);
let br_p = (
computed.map_x(x_coords[col + 1]),
computed.map_y(y_coords[row + 1]),
);
let bl_p = (
computed.map_x(x_coords[col]),
computed.map_y(y_coords[row + 1]),
);
let avg = (tl + tr + br + bl) / 4.0;
match case {
0 => {}
1 => {
let (L, B) = (v(col, row), h(col, row + 1));
poly(&[bl_p, L, B]);
}
2 => {
let (B, R) = (h(col, row + 1), v(col + 1, row));
poly(&[br_p, B, R]);
}
4 => {
let (T, R) = (h(col, row), v(col + 1, row));
poly(&[tr_p, T, R]);
}
8 => {
let (T, L) = (h(col, row), v(col, row));
poly(&[tl_p, T, L]);
}
3 => {
let (L, R) = (v(col, row), v(col + 1, row));
poly(&[bl_p, br_p, R, L]);
}
6 => {
let (T, B) = (h(col, row), h(col, row + 1));
poly(&[T, tr_p, br_p, B]);
}
9 => {
let (T, B) = (h(col, row), h(col, row + 1));
poly(&[tl_p, T, B, bl_p]);
}
12 => {
let (L, R) = (v(col, row), v(col + 1, row));
poly(&[tl_p, tr_p, R, L]);
}
5 => {
let (T, B, L, R) = (h(col, row), h(col, row + 1), v(col, row), v(col + 1, row));
if avg >= t {
poly(&[bl_p, L, T, tr_p, R, B]);
} else {
poly(&[bl_p, L, B]);
poly(&[tr_p, T, R]);
}
}
10 => {
let (T, B, L, R) = (h(col, row), h(col, row + 1), v(col, row), v(col + 1, row));
if avg >= t {
poly(&[tl_p, T, R, br_p, B, L]);
} else {
poly(&[tl_p, T, L]);
poly(&[br_p, B, R]);
}
}
7 => {
let (T, L) = (h(col, row), v(col, row));
poly(&[T, tr_p, br_p, bl_p, L]);
}
11 => {
let (T, R) = (h(col, row), v(col + 1, row));
poly(&[T, tl_p, bl_p, br_p, R]);
}
13 => {
let (R, B) = (v(col + 1, row), h(col, row + 1));
poly(&[tl_p, tr_p, R, B, bl_p]);
}
14 => {
let (L, B) = (v(col, row), h(col, row + 1));
poly(&[L, tl_p, tr_p, br_p, B]);
}
15 => {
poly(&[tl_p, tr_p, br_p, bl_p]);
}
_ => {}
}
}
}
d
}
fn add_contour(cp: &ContourPlot, scene: &mut Scene, computed: &ComputedLayout) {
if cp.z.is_empty() || cp.x_coords.len() < 2 || cp.y_coords.len() < 2 {
return;
}
let levels = cp.effective_levels();
if levels.is_empty() {
return;
}
let (z_min, z_max) = cp.z_range();
let z_span = z_max - z_min + f64::EPSILON;
let level_color = |level: f64| -> String {
let norm = (level - z_min) / z_span;
cp.color_map.map(norm.clamp(0.0, 1.0))
};
if cp.filled {
let x0_d = cp.x_coords.iter().cloned().fold(f64::INFINITY, f64::min);
let x1_d = cp
.x_coords
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let y0_d = cp.y_coords.iter().cloned().fold(f64::INFINITY, f64::min);
let y1_d = cp
.y_coords
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let px0 = computed.map_x(x0_d).min(computed.map_x(x1_d));
let px1 = computed.map_x(x0_d).max(computed.map_x(x1_d));
let py0 = computed.map_y(y0_d).min(computed.map_y(y1_d));
let py1 = computed.map_y(y0_d).max(computed.map_y(y1_d));
scene.add(Primitive::Rect {
x: px0,
y: py0,
width: px1 - px0,
height: py1 - py0,
fill: cp.color_map.map(0.0).into(),
stroke: None,
stroke_width: None,
opacity: None,
});
for &lvl in &levels {
let color = level_color(lvl);
let d = contour_fill_path(&cp.z, &cp.x_coords, &cp.y_coords, lvl, computed);
if d.is_empty() {
continue;
}
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.into()),
stroke: "none".into(),
stroke_width: 0.0,
opacity: None,
stroke_dasharray: None,
})));
}
for &lvl in &levels {
let stroke = cp.line_color.clone().unwrap_or_else(|| "black".to_string());
let d = contour_path(&cp.z, &cp.x_coords, &cp.y_coords, lvl, computed);
if d.is_empty() {
continue;
}
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: stroke.into(),
stroke_width: cp.line_width,
opacity: None,
stroke_dasharray: None,
})));
}
} else {
for &lvl in &levels {
let stroke = cp.line_color.clone().unwrap_or_else(|| level_color(lvl));
let d = contour_path(&cp.z, &cp.x_coords, &cp.y_coords, lvl, computed);
if d.is_empty() {
continue;
}
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: stroke.into(),
stroke_width: cp.line_width,
opacity: None,
stroke_dasharray: None,
})));
}
}
}
fn add_chord(chord: &ChordPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
use std::f64::consts::TAU;
let n = chord.n_nodes();
if n == 0 {
return;
}
let fallback = Palette::category10();
let node_color = |i: usize| -> String {
if let Some(c) = chord.colors.get(i) {
if !c.is_empty() {
return c.clone();
}
}
fallback[i % fallback.len()].to_string()
};
let label_margin = computed.body_size as f64 * 2.5;
let outer_r =
(computed.plot_width().min(computed.plot_height()) / 2.0 - label_margin).max(10.0);
let inner_r = outer_r * chord.pad_fraction;
let cx = computed.margin_left + computed.plot_width() / 2.0;
let cy = computed.margin_top + computed.plot_height() / 2.0;
let row_total: Vec<f64> = chord.matrix.iter().map(|row| row.iter().sum()).collect();
let grand_total: f64 = row_total.iter().sum();
if grand_total <= 0.0 {
return;
}
let gap_rad = chord.gap_degrees.to_radians();
let usable = TAU - n as f64 * gap_rad;
let mut node_start = Vec::with_capacity(n);
let mut node_span = Vec::with_capacity(n);
let mut angle = -std::f64::consts::FRAC_PI_2; for &total in &row_total {
node_start.push(angle);
let span = if grand_total > 0.0 {
(total / grand_total) * usable
} else {
usable / n as f64
};
node_span.push(span);
angle += span + gap_rad;
}
let la = |sweep: f64| if sweep > std::f64::consts::PI { 1 } else { 0 };
for i in 0..n {
let a0 = node_start[i];
let a1 = a0 + node_span[i];
let x1o = cx + outer_r * a0.cos();
let y1o = cy + outer_r * a0.sin();
let x2o = cx + outer_r * a1.cos();
let y2o = cy + outer_r * a1.sin();
let x2i = cx + inner_r * a1.cos();
let y2i = cy + inner_r * a1.sin();
let x1i = cx + inner_r * a0.cos();
let y1i = cy + inner_r * a0.sin();
let laf = la(node_span[i]);
let d = format!(
"M {x1o} {y1o} A {outer_r} {outer_r} 0 {laf} 1 {x2o} {y2o} \
L {x2i} {y2i} A {inner_r} {inner_r} 0 {laf} 0 {x1i} {y1i} Z"
);
let color = node_color(i);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.into()),
stroke: "none".into(),
stroke_width: 0.0,
opacity: None,
stroke_dasharray: None,
})));
}
let label_gap = outer_r + computed.body_size as f64 * 1.6;
for i in 0..n {
let mid = node_start[i] + node_span[i] / 2.0;
let lx = cx + label_gap * mid.cos();
let ly = cy + label_gap * mid.sin() + computed.body_size as f64 * 0.35;
let label = if let Some(l) = chord.labels.get(i) {
l.clone()
} else {
format!("{i}")
};
let anchor = if mid.cos() >= 0.0 {
TextAnchor::Start
} else {
TextAnchor::End
};
scene.add(Primitive::Text {
x: lx,
y: ly,
content: label,
size: computed.body_size,
anchor,
rotate: None,
bold: false,
color: None,
});
}
let mut sub_start = vec![vec![0.0f64; n]; n];
{
let mut cursors = node_start.clone();
#[allow(clippy::needless_range_loop)]
for i in 0..n {
for j in 0..n {
sub_start[i][j] = cursors[i];
let flow = chord
.matrix
.get(i)
.and_then(|r| r.get(j))
.copied()
.unwrap_or(0.0);
if grand_total > 0.0 {
cursors[i] += (flow / grand_total) * usable;
}
}
}
}
#[allow(clippy::needless_range_loop)]
for i in 0..n {
for j in 0..=i {
let flow_ij = chord
.matrix
.get(i)
.and_then(|r| r.get(j))
.copied()
.unwrap_or(0.0);
let flow_ji = chord
.matrix
.get(j)
.and_then(|r| r.get(i))
.copied()
.unwrap_or(0.0);
if i == j {
if flow_ij <= 0.0 {
continue;
}
let a0 = sub_start[i][j];
let span = if grand_total > 0.0 {
(flow_ij / grand_total) * usable
} else {
0.0
};
if span <= 0.0 {
continue;
}
let a1 = a0 + span;
let laf = la(span);
let x1 = cx + inner_r * a0.cos();
let y1 = cy + inner_r * a0.sin();
let x2 = cx + inner_r * a1.cos();
let y2 = cy + inner_r * a1.sin();
let d = format!(
"M {x1} {y1} A {inner_r} {inner_r} 0 {laf} 1 {x2} {y2} \
C {cx} {cy} {cx} {cy} {x1} {y1} Z"
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(node_color(i).into()),
stroke: "none".into(),
stroke_width: 0.0,
opacity: Some(chord.ribbon_opacity),
stroke_dasharray: None,
})));
continue;
}
if flow_ij <= 0.0 && flow_ji <= 0.0 {
continue;
}
let a_i0 = sub_start[i][j];
let span_i = if grand_total > 0.0 {
(flow_ij / grand_total) * usable
} else {
0.0
};
let a_i1 = a_i0 + span_i;
let a_j0 = sub_start[j][i];
let span_j = if grand_total > 0.0 {
(flow_ji / grand_total) * usable
} else {
0.0
};
let a_j1 = a_j0 + span_j;
let xi1 = cx + inner_r * a_i0.cos();
let yi1 = cy + inner_r * a_i0.sin();
let xi2 = cx + inner_r * a_i1.cos();
let yi2 = cy + inner_r * a_i1.sin();
let xj1 = cx + inner_r * a_j0.cos();
let yj1 = cy + inner_r * a_j0.sin();
let xj2 = cx + inner_r * a_j1.cos();
let yj2 = cy + inner_r * a_j1.sin();
let laf_i = la(span_i);
let laf_j = la(span_j);
let d = format!(
"M {xi1} {yi1} \
A {inner_r} {inner_r} 0 {laf_i} 1 {xi2} {yi2} \
C {cx} {cy} {cx} {cy} {xj1} {yj1} \
A {inner_r} {inner_r} 0 {laf_j} 1 {xj2} {yj2} \
C {cx} {cy} {cx} {cy} {xi1} {yi1} Z"
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(node_color(i).into()),
stroke: "none".into(),
stroke_width: 0.0,
opacity: Some(chord.ribbon_opacity),
stroke_dasharray: None,
})));
}
}
}
fn wompwomp_default_colors() -> Vec<String> {
[
"#D55E00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#E69F00", "#CC79A7", "#666666",
"#AD7700", "#1C91D4", "#007756", "#D5C711", "#005685", "#A04700", "#B14380", "#4D4D4D",
"#FFBE2D", "#80C7EF", "#00F6B3", "#F4EB71", "#06A5FF", "#FF8320", "#D99BBD", "#8C8C8C",
"#FFCB57", "#9AD2F2", "#2CFFC6", "#F6EF8E", "#38B7FF", "#FF9B4D", "#E0AFCA", "#A3A3A3",
"#8A5F00", "#1674A9", "#005F45", "#AA9F0D", "#00446B", "#803800", "#8D3666", "#3D3D3D",
]
.into_iter()
.map(str::to_string)
.collect()
}
fn sankey_palette_colors(sankey: &SankeyPlot) -> Vec<String> {
if let Some(colors) = &sankey.palette {
return colors.clone();
}
match sankey.node_coloring {
SankeyNodeColoring::Left => wompwomp_default_colors(),
SankeyNodeColoring::Label => {
let fallback = Palette::category10();
fallback.colors().to_vec()
}
}
}
fn resolve_label_colors(sankey: &SankeyPlot, palette: &[String]) -> Vec<String> {
let mut label_colors: HashMap<&str, String> = HashMap::new();
let mut next_color = 0usize;
for node in &sankey.nodes {
label_colors.entry(&node.label).or_insert_with(|| {
node.color.clone().unwrap_or_else(|| {
let color = palette[next_color % palette.len()].clone();
next_color += 1;
color
})
});
}
sankey
.nodes
.iter()
.map(|node| {
node.color.clone().unwrap_or_else(|| {
label_colors
.get(node.label.as_str())
.cloned()
.unwrap_or_else(|| palette[0].clone())
})
})
.collect()
}
fn display_axes(sankey: &SankeyPlot, nodes_in_col: &[Vec<usize>]) -> Vec<Option<usize>> {
nodes_in_col
.iter()
.map(|members| members.first().and_then(|&idx| sankey.nodes[idx].column))
.collect()
}
fn left_pair_weights(
sankey: &SankeyPlot,
left_members: &[usize],
right_members: &[usize],
left_axis: Option<usize>,
right_axis: Option<usize>,
) -> HashMap<(usize, usize), f64> {
let mut weights = HashMap::new();
if !sankey.alluvia.is_empty() {
if let (Some(left_axis), Some(right_axis)) = (left_axis, right_axis) {
for alluvium in &sankey.alluvia {
if left_axis < alluvium.nodes.len() && right_axis < alluvium.nodes.len() {
let src = alluvium.nodes[left_axis];
let dst = alluvium.nodes[right_axis];
if left_members.contains(&src) && right_members.contains(&dst) {
*weights.entry((src, dst)).or_insert(0.0) += alluvium.value;
}
}
}
}
return weights;
}
for link in &sankey.links {
if left_members.contains(&link.source) && right_members.contains(&link.target) {
*weights.entry((link.source, link.target)).or_insert(0.0) += link.value;
}
}
weights
}
fn resolve_left_colors(
sankey: &SankeyPlot,
nodes_in_col: &[Vec<usize>],
palette: &[String],
) -> Vec<String> {
let mut colors: Vec<Option<String>> = vec![None; sankey.nodes.len()];
for (idx, node) in sankey.nodes.iter().enumerate() {
if let Some(color) = &node.color {
colors[idx] = Some(color.clone());
}
}
if nodes_in_col.is_empty() {
return colors
.into_iter()
.map(|c| c.unwrap_or_else(|| palette[0].clone()))
.collect();
}
let mut next_color_idx = 0usize;
for &node_idx in &nodes_in_col[0] {
if colors[node_idx].is_none() {
colors[node_idx] = Some(palette[next_color_idx % palette.len()].clone());
next_color_idx += 1;
}
}
let axes = display_axes(sankey, nodes_in_col);
for col_idx in 1..nodes_in_col.len() {
let pair_weights = left_pair_weights(
sankey,
&nodes_in_col[col_idx - 1],
&nodes_in_col[col_idx],
axes[col_idx - 1],
axes[col_idx],
);
for &node_idx in &nodes_in_col[col_idx] {
if colors[node_idx].is_some() {
continue;
}
let mut total = 0.0;
let mut best_parent = None;
let mut best_weight = f64::NEG_INFINITY;
for &parent_idx in &nodes_in_col[col_idx - 1] {
let weight = pair_weights
.get(&(parent_idx, node_idx))
.copied()
.unwrap_or(0.0);
total += weight;
if weight > best_weight {
best_weight = weight;
best_parent = Some(parent_idx);
}
}
let share = if total > 0.0 {
best_weight / total
} else {
0.0
};
if share > sankey.left_color_cutoff {
if let Some(parent_idx) = best_parent {
if let Some(parent_color) = &colors[parent_idx] {
colors[node_idx] = Some(parent_color.clone());
}
}
}
if colors[node_idx].is_none() {
colors[node_idx] = Some(palette[next_color_idx % palette.len()].clone());
next_color_idx += 1;
}
}
}
colors
.into_iter()
.map(|c| c.unwrap_or_else(|| palette[0].clone()))
.collect()
}
fn resolve_sankey_node_colors(sankey: &SankeyPlot, nodes_in_col: &[Vec<usize>]) -> Vec<String> {
let palette = sankey_palette_colors(sankey);
match sankey.node_coloring {
SankeyNodeColoring::Label => resolve_label_colors(sankey, &palette),
SankeyNodeColoring::Left => resolve_left_colors(sankey, nodes_in_col, &palette),
}
}
pub fn render_chord(chord: &ChordPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_chord(chord, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_sankey(sankey: &SankeyPlot, scene: &mut Scene, computed: &ComputedLayout) {
if sankey.nodes.is_empty() || sankey.links.is_empty() {
return;
}
let n = sankey.nodes.len();
let mut col: Vec<Option<usize>> = sankey.nodes.iter().map(|nd| nd.column).collect();
let mut has_incoming = vec![false; n];
for link in &sankey.links {
has_incoming[link.target] = true;
}
for i in 0..n {
if col[i].is_none() && !has_incoming[i] {
col[i] = Some(0);
}
}
let mut changed = true;
while changed {
changed = false;
for link in &sankey.links {
let src = link.source;
let tgt = link.target;
if let Some(sc) = col[src] {
let new_tgt_col = sc + 1;
if col[tgt].is_none_or(|tc| tc < new_tgt_col) {
col[tgt] = Some(new_tgt_col);
changed = true;
}
}
}
}
let max_assigned = col.iter().flatten().copied().max().unwrap_or(0);
for c in col.iter_mut() {
if c.is_none() {
*c = Some(max_assigned + 1);
}
}
let mut col: Vec<usize> = col
.into_iter()
.map(|c| c.expect("all Sankey node columns assigned by BFS"))
.collect();
let n_cols = col.iter().copied().max().unwrap_or(0) + 1;
let mut out_flow = vec![0.0_f64; n];
let mut in_flow = vec![0.0_f64; n];
for link in &sankey.links {
out_flow[link.source] += link.value;
in_flow[link.target] += link.value;
}
let node_flow: Vec<f64> = (0..n).map(|i| out_flow[i].max(in_flow[i])).collect();
let plot_h = computed.plot_height();
let plot_w = computed.plot_width();
let mut nodes_in_col: Vec<Vec<usize>> = vec![vec![]; n_cols];
for i in 0..n {
nodes_in_col[col[i]].push(i);
}
if sankey.node_order != SankeyNodeOrder::Input {
let node_sort_keys: Vec<String> = if let Some(axis_names) = sankey.axis_names.as_ref() {
sankey
.nodes
.iter()
.map(|node| {
let axis_name = node
.column
.and_then(|col_idx| axis_names.get(col_idx))
.cloned()
.unwrap_or_else(|| node.id.clone());
format!("{axis_name}~~{}", node.label)
})
.collect()
} else {
sankey.nodes.iter().map(|node| node.id.clone()).collect()
};
let ordered = optimize_sankey_alluvial_order(
&col,
&nodes_in_col,
&sankey.alluvia,
&sankey.links,
sankey.node_order_seed,
Some(&node_sort_keys),
sankey.node_order == SankeyNodeOrder::Neighbornet,
);
col = ordered.col;
nodes_in_col = ordered.nodes_in_col;
}
let node_colors = resolve_sankey_node_colors(sankey, &nodes_in_col);
let node_color = |i: usize| -> String { node_colors[i].clone() };
let mut node_y = vec![0.0_f64; n];
let mut node_h = vec![0.0_f64; n];
for members in &nodes_in_col {
if members.is_empty() {
continue;
}
let m = members.len();
let total_gap = (m - 1) as f64 * sankey.node_gap;
let usable_h = (plot_h - total_gap).max(1.0);
let total_col_flow: f64 = members.iter().map(|&i| node_flow[i]).sum();
if total_col_flow <= 0.0 {
continue;
}
let total_h: f64 = members
.iter()
.map(|&i| (node_flow[i] / total_col_flow) * usable_h)
.sum();
let start_y = computed.margin_top + (plot_h - total_h - total_gap) / 2.0;
let mut cursor_y = start_y;
for &i in members {
let h = (node_flow[i] / total_col_flow) * usable_h;
node_h[i] = h;
node_y[i] = cursor_y;
cursor_y += h + sankey.node_gap;
}
}
let last_col_label_reserve = 85.0_f64;
let col_w = ((plot_w - last_col_label_reserve) / n_cols as f64).max(10.0);
let node_x: Vec<f64> = (0..n)
.map(|i| computed.margin_left + col[i] as f64 * col_w + (col_w - sankey.node_width) / 2.0)
.collect();
let max_col = col.iter().copied().max().unwrap_or(0);
for i in 0..n {
scene.add(Primitive::Rect {
x: node_x[i],
y: node_y[i],
width: sankey.node_width,
height: node_h[i].max(1.0),
fill: node_color(i).into(),
stroke: None,
stroke_width: None,
opacity: None,
});
}
let mut out_cursor = node_y.clone();
let mut in_cursor = node_y.clone();
let mut pos_in_col = vec![0usize; n];
for members in &nodes_in_col {
for (p, &i) in members.iter().enumerate() {
pos_in_col[i] = p;
}
}
let mut link_order: Vec<usize> = (0..sankey.links.len()).collect();
link_order.sort_by_key(|&li| {
let src = sankey.links[li].source;
let tgt = sankey.links[li].target;
(col[tgt], pos_in_col[tgt], pos_in_col[src])
});
for (link_i, &li) in link_order.iter().enumerate() {
let link = &sankey.links[li];
let src = link.source;
let tgt = link.target;
if link.value <= 0.0 {
continue;
}
if out_flow[src] <= 0.0 || in_flow[tgt] <= 0.0 {
continue;
}
let link_h_out = (link.value / out_flow[src]) * node_h[src];
let link_h_in = (link.value / in_flow[tgt]) * node_h[tgt];
let x_src = node_x[src] + sankey.node_width;
let x_tgt = node_x[tgt];
let cx_mid = (x_src + x_tgt) / 2.0;
let y_src_top = out_cursor[src];
let y_src_bot = y_src_top + link_h_out;
let y_tgt_top = in_cursor[tgt];
let y_tgt_bot = y_tgt_top + link_h_in;
out_cursor[src] += link_h_out;
in_cursor[tgt] += link_h_in;
let d = format!(
"M {x_src} {y_src_top} \
C {cx_mid} {y_src_top} {cx_mid} {y_tgt_top} {x_tgt} {y_tgt_top} \
L {x_tgt} {y_tgt_bot} \
C {cx_mid} {y_tgt_bot} {cx_mid} {y_src_bot} {x_src} {y_src_bot} Z"
);
let fill = match &sankey.link_color {
SankeyLinkColor::Source => node_color(src),
SankeyLinkColor::PerLink => link.color.clone().unwrap_or_else(|| node_color(src)),
SankeyLinkColor::Gradient => {
let grad_id = format!("grad_{link_i}");
let src_color = node_color(src);
let tgt_color = node_color(tgt);
scene.defs.push(format!(
r#"<linearGradient id="{grad_id}" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="{src_color}"/><stop offset="100%" stop-color="{tgt_color}"/></linearGradient>"#
));
format!("url(#{grad_id})")
}
};
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(fill.into()),
stroke: "none".into(),
stroke_width: 0.0,
opacity: Some(sankey.link_opacity),
stroke_dasharray: None,
})));
if sankey.flow_labels || sankey.flow_percent {
let ts = computed.tick_size as f64;
let bs = computed.body_size as f64;
let edge_margin = 4.0_f64;
let src_label_right = if col[src] == 0 {
x_src + edge_margin } else {
let chars = sankey.nodes[src].label.chars().count() as f64;
x_src + 6.0 + chars * bs * 0.6 + edge_margin
};
let clear_end = x_tgt - edge_margin;
let t = if clear_end - src_label_right >= ts {
((src_label_right + clear_end) / 2.0 - x_src) / (x_tgt - x_src)
} else {
0.65
}
.clamp(0.05, 0.95);
let yw_src = (1.0 - t) * (1.0 - t) * (1.0 + 2.0 * t);
let yw_tgt = t * t * (3.0 - 2.0 * t);
let y_top_at_t = yw_src * y_src_top + yw_tgt * y_tgt_top;
let y_bot_at_t = yw_src * y_src_bot + yw_tgt * y_tgt_bot;
let ribbon_h_at_t = y_bot_at_t - y_top_at_t;
if ribbon_h_at_t >= sankey.flow_label_min_height {
let label_x = x_src + t * (x_tgt - x_src);
let label_y = (y_top_at_t + y_bot_at_t) / 2.0 + ts * 0.35;
let text = if sankey.flow_percent {
format!("{:.1}%", (link.value / out_flow[src]) * 100.0)
} else {
let formatted = sankey.flow_label_format.format(link.value);
match &sankey.flow_label_unit {
Some(unit) => format!("{formatted} {unit}"),
None => formatted,
}
};
scene.add(Primitive::Text {
x: label_x,
y: label_y,
content: text,
size: computed.tick_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
for i in 0..n {
let (lx, anchor) = if col[i] == 0 {
(node_x[i] - 6.0, TextAnchor::End)
} else {
(node_x[i] + sankey.node_width + 6.0, TextAnchor::Start)
};
let _ = max_col;
scene.add(Primitive::Text {
x: lx,
y: node_y[i] + node_h[i] / 2.0 + computed.body_size as f64 * 0.35,
content: sankey.nodes[i].label.clone(),
size: computed.body_size,
anchor,
rotate: None,
bold: false,
color: None,
});
}
}
pub fn render_sankey(sankey: &SankeyPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_sankey(sankey, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn parse_inline_markup(text: &str) -> Vec<TextSpan> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut spans: Vec<TextSpan> = Vec::new();
let mut i = 0;
let mut plain = String::new();
let flush = |plain: &mut String, spans: &mut Vec<TextSpan>| {
if !plain.is_empty() {
spans.push(TextSpan::plain(std::mem::take(plain)));
}
};
while i < n {
if chars[i] == '*' && i + 1 < n && chars[i + 1] == '*' {
flush(&mut plain, &mut spans);
i += 2;
let start = i;
while i < n {
if chars[i] == '*' && i + 1 < n && chars[i + 1] == '*' {
let inner: String = chars[start..i].iter().collect();
if !inner.is_empty() {
spans.push(TextSpan {
text: inner,
bold: true,
italic: false,
underline: false,
});
}
i += 2;
break;
}
i += 1;
}
} else if chars[i] == '*' {
flush(&mut plain, &mut spans);
i += 1;
let start = i;
while i < n {
if chars[i] == '*' && (i + 1 >= n || chars[i + 1] != '*') {
let inner: String = chars[start..i].iter().collect();
if !inner.is_empty() {
spans.push(TextSpan {
text: inner,
bold: false,
italic: true,
underline: false,
});
}
i += 1;
break;
}
i += 1;
}
} else if chars[i] == '_' && i + 1 < n && chars[i + 1] == '_' {
flush(&mut plain, &mut spans);
i += 2;
let start = i;
while i < n {
if chars[i] == '_' && i + 1 < n && chars[i + 1] == '_' {
let inner: String = chars[start..i].iter().collect();
if !inner.is_empty() {
spans.push(TextSpan {
text: inner,
bold: false,
italic: false,
underline: true,
});
}
i += 2;
break;
}
i += 1;
}
} else {
plain.push(chars[i]);
i += 1;
}
}
flush(&mut plain, &mut spans);
spans
}
fn wrap_rich_spans(spans: &[TextSpan], max_chars: usize) -> Vec<Vec<TextSpan>> {
let mut words: Vec<(bool, bool, bool, String)> = Vec::new();
for span in spans {
for word in span.text.split_whitespace() {
words.push((span.bold, span.italic, span.underline, word.to_string()));
}
}
let mut lines: Vec<Vec<(bool, bool, bool, String)>> = Vec::new();
let mut cur: Vec<(bool, bool, bool, String)> = Vec::new();
let mut cur_len = 0usize;
for (bold, italic, underline, word) in words {
let wlen = word.chars().count();
let sep = if cur.is_empty() { 0 } else { 1 };
if cur_len + sep + wlen > max_chars && !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
cur_len = 0;
}
cur_len += if cur.is_empty() { wlen } else { wlen + 1 };
cur.push((bold, italic, underline, word));
}
if !cur.is_empty() {
lines.push(cur);
}
lines
.into_iter()
.map(|line_words| {
let mut line_spans: Vec<TextSpan> = Vec::new();
for (i, (bold, italic, underline, word)) in line_words.into_iter().enumerate() {
if let Some(last) = line_spans.last_mut() {
if last.bold == bold && last.italic == italic && last.underline == underline {
last.text.push(' ');
last.text.push_str(&word);
continue;
}
}
let text = if i == 0 { word } else { format!(" {}", word) };
line_spans.push(TextSpan {
text,
bold,
italic,
underline,
});
}
line_spans
})
.collect()
}
fn add_legend_plot(lp: &LegendPlot, scene: &mut Scene, computed: &ComputedLayout) {
let line_height = computed.legend_line_height;
let legend_padding = computed.legend_padding;
let theme = &computed.theme;
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let plot_top = computed.margin_top;
let avail_w = plot_right - plot_left;
let n_entries = lp.entries.len();
let mut n_cols = lp.cols.unwrap_or_else(|| {
let max_chars = lp.entries.iter().map(|e| e.label.len()).max().unwrap_or(8) as f64;
let char_px = computed.body_size as f64 * 0.68;
let col_w = 18.0 + max_chars * char_px + 20.0;
((avail_w / col_w).floor() as usize).max(1)
});
let mut n_rows = n_entries.div_ceil(n_cols);
let title_h = if lp.title.is_some() { line_height } else { 0.0 };
let avail_h = computed.height - plot_top - title_h - legend_padding * 2.0;
while n_cols < n_entries && (n_rows as f64 * line_height) > avail_h {
n_cols += 1;
n_rows = n_entries.div_ceil(n_cols);
}
let mut cur_y = plot_top;
if let Some(ref title) = lp.title {
scene.add(Primitive::Text {
x: plot_left + avail_w / 2.0,
y: cur_y + 5.0,
content: title.clone(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
cur_y += line_height;
}
let legend_y = cur_y;
let col_w = avail_w / n_cols as f64;
if lp.show_box && n_entries > 0 {
let box_h = n_rows as f64 * line_height + legend_padding * 2.0;
scene.add(Primitive::Rect {
x: plot_left - legend_padding + 5.0,
y: legend_y - legend_padding,
width: avail_w + legend_padding,
height: box_h,
fill: Color::from(&theme.legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: plot_left - legend_padding + 5.0,
y: legend_y - legend_padding,
width: avail_w + legend_padding,
height: box_h,
fill: "none".into(),
stroke: Some(Color::from(&theme.legend_border)),
stroke_width: Some(computed.axis_stroke_width),
opacity: None,
});
}
for (i, entry) in lp.entries.iter().enumerate() {
let col = i % n_cols;
let row = i / n_cols;
let ex = plot_left + col as f64 * col_w;
let ey = legend_y + row as f64 * line_height;
render_legend_entry(entry, scene, ex, ey, computed);
}
}
fn add_text_plot(tp: &TextPlot, scene: &mut Scene, computed: &ComputedLayout) {
let px = computed.margin_left;
let py = computed.margin_top;
let pw = computed.plot_width();
let full_h = computed.height - py;
let padding = tp.padding;
if let Some(ref bg) = tp.background {
scene.add(Primitive::Rect {
x: px,
y: py,
width: pw,
height: full_h,
fill: Color::from(bg.as_str()),
stroke: None,
stroke_width: None,
opacity: None,
});
}
if tp.border_width > 0.0 {
scene.add(Primitive::Rect {
x: px,
y: py,
width: pw,
height: full_h,
fill: Color::None,
stroke: Some(Color::from(tp.border_color.as_deref().unwrap_or("#cccccc"))),
stroke_width: Some(tp.border_width),
opacity: None,
});
}
let font_size = tp.font_size.unwrap_or(computed.body_size);
let line_height = font_size as f64 * 1.55;
let avail_w = (pw - 2.0 * padding).max(20.0);
let char_w = font_size as f64 * 0.55;
let max_chars = ((avail_w / char_w) as usize).max(1);
let (anchor, text_x) = match tp.text_align {
TextAlign::Left => (TextAnchor::Start, px + padding),
TextAlign::Center => (TextAnchor::Middle, px + pw / 2.0),
TextAlign::Right => (TextAnchor::End, px + pw - padding),
};
let text_color = tp.text_color.as_deref().map(Color::from);
let mut cy = py + padding + font_size as f64;
if let Some(ref t) = tp.title {
scene.add(Primitive::Text {
x: text_x,
y: cy,
content: t.clone(),
size: font_size + 2,
anchor,
rotate: None,
bold: true,
color: text_color.clone(),
});
cy += line_height * 1.5;
}
for raw in tp.body.lines() {
if raw.trim() == "---" {
let rule_y = cy - font_size as f64 * 0.4;
scene.add(Primitive::Line {
x1: px + padding,
y1: rule_y,
x2: px + pw - padding,
y2: rule_y,
stroke: Color::from("#cccccc"),
stroke_width: 1.0,
stroke_dasharray: None,
});
cy += line_height * 0.5;
continue;
}
let (heading_text, size_bump) = if let Some(s) = raw.strip_prefix("## ") {
(Some(s), 2u32)
} else if let Some(s) = raw.strip_prefix("# ") {
(Some(s), 4u32)
} else {
(None, 0u32)
};
if let Some(text) = heading_text {
if text.trim().is_empty() {
cy += line_height * 0.5;
continue;
}
let fs = font_size + size_bump;
let lh = fs as f64 * 1.55;
let mc = ((avail_w / (fs as f64 * 0.55)) as usize).max(1);
let words: Vec<&str> = text.split_whitespace().collect();
let mut cur = String::new();
for word in words {
let sep = if cur.is_empty() { 0 } else { 1 };
if cur.len() + sep + word.len() > mc && !cur.is_empty() {
scene.add(Primitive::Text {
x: text_x,
y: cy,
content: std::mem::take(&mut cur),
size: fs,
anchor,
rotate: None,
bold: true,
color: text_color.clone(),
});
cy += lh;
}
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
}
if !cur.is_empty() {
scene.add(Primitive::Text {
x: text_x,
y: cy,
content: cur,
size: fs,
anchor,
rotate: None,
bold: true,
color: text_color.clone(),
});
cy += lh;
}
cy += lh * 0.2;
continue;
}
if raw.trim().is_empty() {
cy += line_height * 0.5;
continue;
}
let spans = parse_inline_markup(raw);
let wrapped_lines = wrap_rich_spans(&spans, max_chars);
for line_spans in wrapped_lines {
scene.add(Primitive::RichText {
x: text_x,
y: cy,
spans: line_spans,
size: font_size,
anchor,
color: text_color.clone(),
});
cy += line_height;
}
}
}
pub fn render_multiple(plots: Vec<Plot>, layout: Layout) -> Scene {
let mut plots = plots;
let mut layout = layout;
if let Some(ref palette) = layout.palette {
let mut color_idx = 0;
for plot in plots.iter_mut() {
match plot {
Plot::Scatter(_)
| Plot::Line(_)
| Plot::Series(_)
| Plot::Histogram(_)
| Plot::Box(_)
| Plot::Violin(_)
| Plot::Band(_)
| Plot::Strip(_)
| Plot::Density(_)
| Plot::Forest(_)
| Plot::Scatter3D(_)
| Plot::Surface3D(_)
| Plot::Raincloud(_)
| Plot::Lollipop(_)
| Plot::Survival(_)
| Plot::Slope(_)
| Plot::Ecdf(_)
| Plot::QQ(_) => {
plot.set_color(&palette[color_idx]);
color_idx += 1;
}
_ => {}
}
}
}
for plot in plots.iter_mut() {
if let Plot::Network(ref mut net) = plot {
net.resolve_matrix();
}
}
let mut computed = ComputedLayout::from_layout(&layout);
for plot in plots.iter() {
if let Plot::Pie(pie) = plot {
let has_outside = matches!(
pie.label_position,
PieLabelPosition::Outside | PieLabelPosition::Auto
);
if !has_outside {
break;
}
let total: f64 = pie.slices.iter().map(|s| s.value).sum();
if total <= 0.0 {
break;
}
let char_width = computed.body_size as f64 * 0.6;
let max_label_px = pie
.slices
.iter()
.map(|slice| {
let frac = slice.value / total;
let place_inside = match pie.label_position {
PieLabelPosition::None | PieLabelPosition::Inside => true,
PieLabelPosition::Outside => false,
PieLabelPosition::Auto => frac >= pie.min_label_fraction,
};
if place_inside {
return 0.0_f64;
}
let label_text = if pie.show_percent {
let pct = frac * 100.0;
if slice.label.is_empty() {
format!("{:.1}%", pct)
} else {
format!("{} ({:.1}%)", slice.label, pct)
}
} else {
slice.label.clone()
};
label_text.len() as f64 * char_width
})
.fold(0.0_f64, f64::max);
let leader_gap = 30.0;
let pad = 5.0;
let safety = 20.0;
let radius = computed.plot_height() / 2.0 - pad;
let needed_half = radius + leader_gap + max_label_px + pad + safety;
let needed_plot_width = needed_half * 2.0;
if layout.width.is_none() && needed_plot_width > computed.plot_width() {
computed.width = needed_plot_width + computed.margin_left + computed.margin_right;
computed.recompute_transforms();
}
break; }
}
if let Some(Plot::DicePlot(dp)) = plots.iter().find(|p| matches!(p, Plot::DicePlot(_))) {
let nx = dp.x_categories.len().max(1);
let ny = dp.y_categories.len().max(1);
let cw0 = computed.plot_width() / nx as f64;
let ch0 = computed.plot_height() / ny as f64;
if ch0 < cw0 {
let cell_sq = ch0;
let gw = nx as f64 * cell_sq;
let ideal_width = computed.margin_left + gw + 12.0 + computed.margin_right;
if ideal_width < computed.width {
computed.width = ideal_width;
computed.recompute_transforms();
}
}
}
let capacity_hint: usize = plots
.iter()
.map(|p| p.estimated_primitives())
.sum::<usize>()
+ 64;
let mut scene = Scene::with_capacity(computed.width, computed.height, capacity_hint);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
scene.interactive = computed.interactive;
if computed.interactive {
let skip_axes_for_meta = plots.iter().all(|p| {
matches!(
p,
Plot::Pie(_)
| Plot::UpSet(_)
| Plot::Chord(_)
| Plot::Sankey(_)
| Plot::PhyloTree(_)
| Plot::Synteny(_)
| Plot::Polar(_)
| Plot::Ternary(_)
| Plot::Scatter3D(_)
| Plot::Surface3D(_)
| Plot::Clustermap(_)
| Plot::Joint(_)
| Plot::Venn(_)
| Plot::Parallel(_)
| Plot::Mosaic(_)
| Plot::Network(_)
| Plot::Radar(_)
| Plot::Treemap(_)
| Plot::Sunburst(_)
| Plot::Funnel(_)
| Plot::Rose(_)
| Plot::Calendar(_)
| Plot::Waffle(_)
| Plot::Text(_)
| Plot::LegendPlot(_)
)
});
if !skip_axes_for_meta {
scene.axis_meta = Some(AxisMeta {
x_min: computed.x_range.0,
x_max: computed.x_range.1,
y_min: computed.y_range.0,
y_max: computed.y_range.1,
plot_left: computed.margin_left,
plot_top: computed.margin_top,
plot_right: computed.width - computed.margin_right,
plot_bottom: computed.height - computed.margin_bottom,
log_x: computed.log_x,
log_y: computed.log_y,
});
}
}
let skip_axes = plots.iter().all(|p| {
matches!(
p,
Plot::Pie(_)
| Plot::UpSet(_)
| Plot::Chord(_)
| Plot::Sankey(_)
| Plot::PhyloTree(_)
| Plot::Synteny(_)
| Plot::Polar(_)
| Plot::Ternary(_)
| Plot::DicePlot(_)
| Plot::Scatter3D(_)
| Plot::Surface3D(_)
| Plot::Clustermap(_)
| Plot::Joint(_)
| Plot::Venn(_)
| Plot::Parallel(_)
| Plot::Mosaic(_)
| Plot::Network(_)
| Plot::Radar(_)
| Plot::Treemap(_)
| Plot::Sunburst(_)
| Plot::Funnel(_)
| Plot::Rose(_)
| Plot::Calendar(_)
| Plot::Waffle(_)
| Plot::Text(_)
| Plot::LegendPlot(_)
)
});
if !skip_axes {
add_axes_and_grid(&mut scene, &computed, &layout);
}
if let Some(Plot::DicePlot(dp)) = plots.iter().find(|p| matches!(p, Plot::DicePlot(_))) {
let nx = dp.x_categories.len().max(1);
let ny = dp.y_categories.len().max(1);
let cw0 = computed.plot_width() / nx as f64;
let ch0 = computed.plot_height() / ny as f64;
let cell_sq = cw0.min(ch0);
let gw = nx as f64 * cell_sq;
let gh = ny as f64 * cell_sq;
if ch0 < cw0 {
let gap = 12.0_f64;
let new_mr =
(computed.width - computed.margin_left - gw + 2.0 * computed.y2_axis_width + 20.0
- 2.0 * gap)
.max(computed.legend_width + 10.0); computed.margin_right = new_mr;
}
let gx0 = computed.margin_left + (computed.plot_width() - gw) / 2.0;
let gy0 = computed.margin_top + (computed.plot_height() - gh) / 2.0;
let grid_bottom = gy0 + gh;
if ch0 < cw0 {
let gap = 12.0_f64;
computed.colorbar_x_inset = (computed.width - gx0 - gw - gap).max(0.0);
}
let tl = computed.tick_mark_major;
let tlm = computed.tick_label_margin;
let ts = computed.tick_size as f64;
let ls = computed.label_size as f64;
let x_label_y = grid_bottom + tl + tlm + ts * 0.85 + 6.0 + ls * 0.5;
computed.dice_x_label_pos = Some((gx0 + gw / 2.0, x_label_y));
let max_y_px = dp.y_categories.iter().map(|s| s.len()).max().unwrap_or(4) as f64 * ts * 0.6;
let y_label_x = (gx0 - tl - tlm - max_y_px - 6.0 - ls * 0.5).max(ls * 0.5 + 4.0);
computed.dice_y_label_pos = Some((y_label_x, gy0 + gh / 2.0));
}
if plots.iter().any(|p| matches!(p, Plot::Joint(_))) {
layout.x_label = None;
layout.y_label = None;
}
add_labels_and_title(&mut scene, &computed, &layout);
if !skip_axes {
let clip_id = next_clip_id();
let clip_def = format!(
r#"<clipPath id="{id}"><rect x="{x}" y="{y}" width="{w}" height="{h}"/></clipPath>"#,
id = clip_id,
x = round2(computed.margin_left),
y = round2(computed.margin_top),
w = round2(computed.plot_width()),
h = round2(computed.plot_height()),
);
scene.defs.push(clip_def);
scene.elements.push(Primitive::ClipStart {
x: computed.margin_left,
y: computed.margin_top,
width: computed.plot_width(),
height: computed.plot_height(),
id: clip_id,
});
}
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
for plot in plots.iter() {
match plot {
Plot::Scatter(s) => {
add_scatter(s, &mut scene, &computed);
}
Plot::Line(l) => {
add_line(l, &mut scene, &computed);
}
Plot::Series(s) => {
add_series(s, &mut scene, &computed);
}
Plot::Bar(b) => {
add_bar(b, &mut scene, &computed);
}
Plot::Histogram(h) => {
add_histogram(h, &mut scene, &computed);
}
Plot::Histogram2d(h) => {
add_histogram2d(h, &mut scene, &computed);
}
Plot::Box(b) => {
add_boxplot(b, &mut scene, &computed);
}
Plot::Violin(v) => {
add_violin(v, &mut scene, &computed);
}
Plot::Pie(p) => {
add_pie(p, &mut scene, &computed);
}
Plot::Heatmap(h) => {
add_heatmap(h, &mut scene, &computed);
}
Plot::Brick(b) => {
add_brickplot(b, &mut scene, &computed);
}
Plot::Band(b) => {
add_band(b, &mut scene, &computed);
}
Plot::Waterfall(w) => {
add_waterfall(w, &mut scene, &computed);
}
Plot::Strip(s) => {
add_strip(s, &mut scene, &computed);
}
Plot::Volcano(v) => {
add_volcano(v, &mut scene, &computed);
}
Plot::Manhattan(m) => {
add_manhattan(m, &mut scene, &computed);
}
Plot::DotPlot(d) => {
add_dot_plot(d, &mut scene, &computed);
}
Plot::UpSet(u) => {
add_upset(u, &mut scene, &computed);
}
Plot::StackedArea(sa) => {
add_stacked_area(sa, &mut scene, &computed);
}
Plot::Candlestick(cp) => {
add_candlestick(cp, &mut scene, &computed);
}
Plot::Contour(cp) => {
add_contour(cp, &mut scene, &computed);
}
Plot::Chord(c) => {
add_chord(c, &mut scene, &computed);
}
Plot::Sankey(s) => {
add_sankey(s, &mut scene, &computed);
}
Plot::PhyloTree(t) => {
add_phylo_tree(t, &mut scene, &computed);
}
Plot::Synteny(s) => {
add_synteny(s, &mut scene, &computed);
}
Plot::Density(d) => {
add_density(d, &computed, &mut scene);
}
Plot::Ridgeline(rp) => {
add_ridgeline(rp, &computed, &mut scene);
}
Plot::Polar(pp) => {
add_polar(pp, &mut scene, &computed);
}
Plot::Ternary(tp) => {
add_ternary(tp, &mut scene, &computed);
}
Plot::DicePlot(d) => {
add_diceplot(d, &mut scene, &computed);
}
Plot::Forest(f) => {
add_forest(f, &mut scene, &computed);
}
Plot::Scatter3D(s) => {
add_scatter3d(s, &mut scene, &computed);
}
Plot::Surface3D(s) => {
add_surface3d(s, &mut scene, &computed);
}
Plot::Clustermap(c) => {
add_clustermap(c, &mut scene, &computed);
}
Plot::Joint(jp) => {
let title_h = if layout.title.is_some() { 35.0 } else { 0.0 };
add_jointplot(
jp,
&mut scene,
&computed,
title_h,
computed.legend_width,
layout.show_legend,
false,
);
}
Plot::Raincloud(r) => {
add_raincloud(r, &mut scene, &computed);
}
Plot::Lollipop(lp) => {
add_lollipop(lp, &mut scene, &computed);
}
Plot::Survival(sp) => {
add_survival(sp, &mut scene, &computed);
}
Plot::Roc(r) => {
add_roc(r, &mut scene, &computed);
}
Plot::Pr(r) => {
add_pr(r, &mut scene, &computed);
}
Plot::Slope(s) => {
add_slope(s, &mut scene, &computed);
}
Plot::Venn(v) => {
add_venn(v, &mut scene, &computed);
}
Plot::Parallel(p) => {
add_parallel(p, &mut scene, &computed);
}
Plot::Mosaic(mp) => {
add_mosaic(mp, &mut scene, &computed);
}
Plot::Ecdf(ep) => {
add_ecdf(ep, &computed, &mut scene);
}
Plot::QQ(qp) => {
add_qqplot(qp, &computed, &mut scene);
}
Plot::Network(n) => {
add_network(n, &mut scene, &computed);
}
Plot::Streamgraph(sg) => {
add_streamgraph(sg, &mut scene, &computed);
}
Plot::Radar(rp) => {
add_radar(rp, &mut scene, &computed);
}
Plot::Hexbin(hb) => {
add_hexbin(hb, &mut scene, &computed);
}
Plot::Treemap(tm) => {
add_treemap(tm, &mut scene, &computed);
}
Plot::Sunburst(sb) => {
add_sunburst(sb, &mut scene, &computed);
}
Plot::Bump(bp) => {
add_bump(bp, &mut scene, &computed, &layout);
}
Plot::Funnel(fp) => {
add_funnel(fp, &mut scene, &computed);
}
Plot::Rose(rp) => {
add_rose(rp, &mut scene, &computed);
}
Plot::Calendar(cp) => {
add_calendar(cp, &mut scene, &computed);
}
Plot::Pyramid(pp) => {
add_pyramid(pp, &mut scene, &computed);
}
Plot::Waffle(wp) => {
add_waffle(wp, &mut scene, &computed);
}
Plot::Horizon(hp) => {
add_horizon(hp, &mut scene, &computed);
}
Plot::Gantt(gp) => {
add_gantt(gp, &mut scene, &computed);
}
Plot::Text(tp) => {
add_text_plot(tp, &mut scene, &computed);
}
Plot::LegendPlot(lp) => {
add_legend_plot(lp, &mut scene, &computed);
}
}
}
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
if !skip_axes {
scene.elements.push(Primitive::ClipEnd);
}
for plot in plots.iter() {
if let Plot::Manhattan(m) = plot {
add_manhattan_chr_labels(m, &mut scene, &computed);
}
}
for plot in plots.iter() {
if let Plot::Brick(bp) = plot {
add_brickplot_notations(bp, &mut scene, &computed);
}
}
for plot in plots.iter() {
if let Plot::Horizon(hp) = plot {
add_horizon_annots(hp, &mut scene, &computed);
}
}
for plot in plots.iter() {
if let Plot::Gantt(gp) = plot {
add_gantt_labels(gp, &mut scene, &computed);
}
}
let dot_stacked = plots.iter().find_map(|p| {
if let Plot::DotPlot(dp) = p {
if dp.size_label.is_some() && dp.color_legend_label.is_some() {
p.colorbar_info().map(|info| (dp, info))
} else {
None
}
} else {
None
}
});
if let Some((dp, info)) = dot_stacked {
let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
let mut size_entries = Vec::new();
for &pct in &[0.25_f64, 0.50, 0.75, 1.0] {
let value_at_pct = size_min + pct * (size_max - size_min);
let radius_at_pct = dp.max_radius * pct;
size_entries.push(LegendEntry {
label: format!("{:.1}", value_at_pct),
color: "#444444".into(),
shape: LegendShape::CircleSize(radius_at_pct),
dasharray: None,
});
}
let title = dp.size_label.as_deref().unwrap_or("");
add_dot_stacked_legends(title, &size_entries, &info, &mut scene, &computed);
} else {
let dice_has_legend = plots.iter().any(|p| {
if let Plot::DicePlot(dp) = p {
dp.position_legend_label.is_some()
|| !dp.dot_legend.is_empty()
|| dp.size_legend_label.is_some()
} else {
false
}
});
let mut dice_colorbar_drawn = false;
if dice_has_legend {
if layout.show_legend {
for plot in plots.iter() {
if let Plot::DicePlot(dp) = plot {
if dp.position_legend_label.is_some()
|| !dp.dot_legend.is_empty()
|| dp.size_legend_label.is_some()
{
dice_colorbar_drawn = add_dice_legends(dp, &mut scene, &computed);
break;
}
}
}
}
} else {
let (entries, groups) = if let Some(ref grps) = layout.legend_groups {
(Vec::new(), Some(grps.clone()))
} else {
let e = layout
.legend_entries
.clone()
.unwrap_or_else(|| collect_legend_entries(&plots));
(e, None)
};
let has_legend = layout.show_legend && (!entries.is_empty() || groups.is_some());
let stats_result = add_stats_box(&layout, &mut scene, &computed);
if has_legend {
let legend = Legend {
title: layout.legend_title.clone(),
entries,
groups,
position: layout.legend_position,
show_box: layout.legend_box,
};
let y_offset = if let Some((_, _stats_y, stats_h)) = stats_result {
let positions_match = std::mem::discriminant(&layout.stats_position)
== std::mem::discriminant(&layout.legend_position);
if positions_match {
stats_h + 8.0
} else {
0.0
}
} else {
0.0
};
add_legend_with_offset(&legend, &mut scene, &computed, y_offset);
}
}
if layout.show_colorbar && !dice_colorbar_drawn {
let mut special_cb_drawn = false;
for plot in plots.iter() {
if let Plot::Hexbin(hb) = plot {
add_hexbin_colorbar(hb, &mut scene, &computed);
special_cb_drawn = true;
break;
}
}
if !special_cb_drawn {
for plot in plots.iter() {
if let Plot::Treemap(tm) = plot {
add_treemap_colorbar(tm, &mut scene, &computed);
special_cb_drawn = true;
break;
}
}
}
if !special_cb_drawn {
for plot in plots.iter() {
if let Plot::Sunburst(sb) = plot {
add_sunburst_colorbar(sb, &mut scene, &computed);
special_cb_drawn = true;
break;
}
}
}
if !special_cb_drawn {
for plot in plots.iter() {
if let Some(info) = plot.colorbar_info() {
add_colorbar(&info, &mut scene, &computed);
break; }
}
}
}
}
scene
}
pub fn render_twin_y(primary: Vec<Plot>, secondary: Vec<Plot>, layout: Layout) -> Scene {
let mut primary = primary;
let mut secondary = secondary;
if let Some(ref palette) = layout.palette {
let mut color_idx = 0;
for plot in primary.iter_mut().chain(secondary.iter_mut()) {
match plot {
Plot::Scatter(_)
| Plot::Line(_)
| Plot::Series(_)
| Plot::Histogram(_)
| Plot::Box(_)
| Plot::Violin(_)
| Plot::Band(_)
| Plot::Strip(_)
| Plot::Density(_)
| Plot::Ecdf(_)
| Plot::QQ(_) => {
plot.set_color(&palette[color_idx]);
color_idx += 1;
}
_ => {}
}
}
}
let computed = ComputedLayout::from_layout(&layout);
let computed_y2 = computed.for_y2();
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_axes_and_grid(&mut scene, &computed, &layout);
add_y2_axis(&mut scene, &computed, &layout);
add_labels_and_title(&mut scene, &computed, &layout);
let clip_id_twin = next_clip_id();
let clip_def_twin = format!(
r#"<clipPath id="{id}"><rect x="{x}" y="{y}" width="{w}" height="{h}"/></clipPath>"#,
id = clip_id_twin,
x = round2(computed.margin_left),
y = round2(computed.margin_top),
w = round2(computed.plot_width()),
h = round2(computed.plot_height()),
);
scene.defs.push(clip_def_twin);
scene.elements.push(Primitive::ClipStart {
x: computed.margin_left,
y: computed.margin_top,
width: computed.plot_width(),
height: computed.plot_height(),
id: clip_id_twin,
});
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
for plot in primary.iter() {
match plot {
Plot::Scatter(s) => add_scatter(s, &mut scene, &computed),
Plot::Line(l) => add_line(l, &mut scene, &computed),
Plot::Series(s) => add_series(s, &mut scene, &computed),
Plot::Band(b) => add_band(b, &mut scene, &computed),
Plot::Bar(b) => add_bar(b, &mut scene, &computed),
Plot::Histogram(h) => add_histogram(h, &mut scene, &computed),
Plot::Box(b) => add_boxplot(b, &mut scene, &computed),
Plot::Violin(v) => add_violin(v, &mut scene, &computed),
Plot::Strip(s) => add_strip(s, &mut scene, &computed),
Plot::Density(d) => add_density(d, &computed, &mut scene),
Plot::StackedArea(s) => add_stacked_area(s, &mut scene, &computed),
Plot::Waterfall(w) => add_waterfall(w, &mut scene, &computed),
Plot::Candlestick(c) => add_candlestick(c, &mut scene, &computed),
Plot::Raincloud(r) => add_raincloud(r, &mut scene, &computed),
Plot::Lollipop(lp) => add_lollipop(lp, &mut scene, &computed),
Plot::Survival(sp) => add_survival(sp, &mut scene, &computed),
Plot::Ecdf(ep) => add_ecdf(ep, &computed, &mut scene),
Plot::QQ(qp) => add_qqplot(qp, &computed, &mut scene),
Plot::Streamgraph(sg) => add_streamgraph(sg, &mut scene, &computed),
_ => {}
}
}
for plot in secondary.iter() {
match plot {
Plot::Scatter(s) => add_scatter(s, &mut scene, &computed_y2),
Plot::Line(l) => add_line(l, &mut scene, &computed_y2),
Plot::Series(s) => add_series(s, &mut scene, &computed_y2),
Plot::Band(b) => add_band(b, &mut scene, &computed_y2),
Plot::Bar(b) => add_bar(b, &mut scene, &computed_y2),
Plot::Histogram(h) => add_histogram(h, &mut scene, &computed_y2),
Plot::Box(b) => add_boxplot(b, &mut scene, &computed_y2),
Plot::Violin(v) => add_violin(v, &mut scene, &computed_y2),
Plot::Strip(s) => add_strip(s, &mut scene, &computed_y2),
Plot::Density(d) => add_density(d, &computed_y2, &mut scene),
Plot::StackedArea(s) => add_stacked_area(s, &mut scene, &computed_y2),
Plot::Streamgraph(sg) => add_streamgraph(sg, &mut scene, &computed_y2),
Plot::Waterfall(w) => add_waterfall(w, &mut scene, &computed_y2),
Plot::Candlestick(c) => add_candlestick(c, &mut scene, &computed_y2),
Plot::Raincloud(r) => add_raincloud(r, &mut scene, &computed_y2),
Plot::Lollipop(lp) => add_lollipop(lp, &mut scene, &computed_y2),
Plot::Survival(sp) => add_survival(sp, &mut scene, &computed_y2),
Plot::Ecdf(ep) => add_ecdf(ep, &computed_y2, &mut scene),
Plot::QQ(qp) => add_qqplot(qp, &computed_y2, &mut scene),
_ => {}
}
}
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene.elements.push(Primitive::ClipEnd);
let mut all_plots_for_legend: Vec<Plot> = primary;
all_plots_for_legend.extend(secondary);
let (entries, groups) = if let Some(ref grps) = layout.legend_groups {
(Vec::new(), Some(grps.clone()))
} else {
let e = layout
.legend_entries
.clone()
.unwrap_or_else(|| collect_legend_entries(&all_plots_for_legend));
(e, None)
};
let stats_result = add_stats_box(&layout, &mut scene, &computed);
if layout.show_legend && (!entries.is_empty() || groups.is_some()) {
let legend = Legend {
title: layout.legend_title.clone(),
entries,
groups,
position: layout.legend_position,
show_box: layout.legend_box,
};
let y_offset = if let Some((_, _stats_y, stats_h)) = stats_result {
let positions_match = std::mem::discriminant(&layout.stats_position)
== std::mem::discriminant(&layout.legend_position);
if positions_match {
stats_h + 8.0
} else {
0.0
}
} else {
0.0
};
add_legend_with_offset(&legend, &mut scene, &computed, y_offset);
}
scene
}
fn add_phylo_tree(tree: &PhyloTree, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::phylo::post_order_dfs;
use std::f64::consts::PI;
let n_nodes = tree.nodes.len();
if n_nodes == 0 {
return;
}
let post_order = post_order_dfs(tree.root, &tree.nodes);
let mut pos: Vec<f64> = vec![0.0; n_nodes];
let mut leaf_counter = 0usize;
for &id in &post_order {
if tree.nodes[id].children.is_empty() {
pos[id] = leaf_counter as f64;
leaf_counter += 1;
} else {
let sum: f64 = tree.nodes[id].children.iter().map(|&c| pos[c]).sum();
pos[id] = sum / tree.nodes[id].children.len() as f64;
}
}
let n_leaves = leaf_counter;
if n_leaves == 0 {
return;
}
let leaves: Vec<usize> = post_order
.iter()
.copied()
.filter(|&id| tree.nodes[id].children.is_empty())
.collect();
let depth: Vec<f64>;
let max_depth_f: f64;
if tree.phylogram {
let mut acc = vec![0.0f64; n_nodes];
let mut queue = std::collections::VecDeque::new();
queue.push_back(tree.root);
while let Some(id) = queue.pop_front() {
for &child in &tree.nodes[id].children {
acc[child] = acc[id] + tree.nodes[child].branch_length;
queue.push_back(child);
}
}
let max_len = leaves.iter().map(|&l| acc[l]).fold(0.0_f64, f64::max);
max_depth_f = if max_len > 0.0 { max_len } else { 1.0 };
depth = acc;
} else {
let mut subtree_depth = vec![0usize; n_nodes];
for &id in &post_order {
if tree.nodes[id].children.is_empty() {
subtree_depth[id] = 0;
} else {
subtree_depth[id] = tree.nodes[id]
.children
.iter()
.map(|&c| subtree_depth[c] + 1)
.max()
.unwrap_or(0);
}
}
let max_depth = subtree_depth[tree.root];
max_depth_f = max_depth as f64;
depth = (0..n_nodes)
.map(|i| (max_depth - subtree_depth[i]) as f64)
.collect();
}
let pw = computed.plot_width();
let ph = computed.plot_height();
let ml = computed.margin_left;
let mt = computed.margin_top;
let max_label_chars = tree
.nodes
.iter()
.filter(|n| n.children.is_empty())
.filter_map(|n| n.label.as_ref())
.map(|l| l.len())
.max()
.unwrap_or(6);
let label_pad = ((max_label_chars as f64) * 7.0 + 20.0).clamp(70.0, 200.0);
let edge_pad = 25.0_f64;
let (eff_ml, eff_mt, eff_pw, eff_ph) = match tree.branch_style {
TreeBranchStyle::Circular => {
let label_gap = 8.0_f64;
let half_line = 7.0_f64; let char_w = 7.0_f64;
let h_clear = edge_pad + label_gap + max_label_chars as f64 * char_w;
let v_clear = edge_pad + half_line;
let max_r = (pw / 2.0 - h_clear).min(ph / 2.0 - v_clear).max(10.0);
let cx = ml + pw / 2.0;
let cy = mt + ph / 2.0;
(cx - max_r, cy - max_r, 2.0 * max_r, 2.0 * max_r)
}
_ => match tree.orientation {
TreeOrientation::Left => (
ml + edge_pad,
mt + edge_pad,
(pw - label_pad - edge_pad).max(50.0),
(ph - 2.0 * edge_pad).max(50.0),
),
TreeOrientation::Right => (
ml + label_pad,
mt + edge_pad,
(pw - label_pad - edge_pad).max(50.0),
(ph - 2.0 * edge_pad).max(50.0),
),
TreeOrientation::Top => (
ml + edge_pad,
mt + edge_pad,
(pw - 2.0 * edge_pad).max(50.0),
(ph - label_pad - edge_pad).max(50.0),
),
TreeOrientation::Bottom => (
ml + edge_pad,
mt + label_pad,
(pw - 2.0 * edge_pad).max(50.0),
(ph - label_pad - edge_pad).max(50.0),
),
},
};
let d_frac = |i: usize| -> f64 {
if max_depth_f > 0.0 {
depth[i] / max_depth_f
} else {
0.0
}
};
let p_frac = |i: usize| -> f64 { (pos[i] + 0.5) / n_leaves as f64 };
let (px, py, r_arr, theta_arr): (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) =
if tree.branch_style == TreeBranchStyle::Circular {
let cx = eff_ml + eff_pw / 2.0;
let cy = eff_mt + eff_ph / 2.0;
let max_r = eff_pw.min(eff_ph) * 0.5;
let mut pxv = vec![0.0; n_nodes];
let mut pyv = vec![0.0; n_nodes];
let mut rv = vec![0.0; n_nodes];
let mut tv = vec![0.0; n_nodes];
for i in 0..n_nodes {
let r = d_frac(i) * max_r;
let theta = p_frac(i) * 2.0 * PI;
rv[i] = r;
tv[i] = theta;
pxv[i] = cx + r * theta.cos();
pyv[i] = cy + r * theta.sin();
}
(pxv, pyv, rv, tv)
} else {
let mut pxv = vec![0.0; n_nodes];
let mut pyv = vec![0.0; n_nodes];
for i in 0..n_nodes {
let df = d_frac(i);
let pf = p_frac(i);
let (x, y) = match tree.orientation {
TreeOrientation::Left => (eff_ml + df * eff_pw, eff_mt + pf * eff_ph),
TreeOrientation::Right => (eff_ml + (1.0 - df) * eff_pw, eff_mt + pf * eff_ph),
TreeOrientation::Top => (eff_ml + pf * eff_pw, eff_mt + df * eff_ph),
TreeOrientation::Bottom => (eff_ml + pf * eff_pw, eff_mt + (1.0 - df) * eff_ph),
};
pxv[i] = x;
pyv[i] = y;
}
(pxv, pyv, vec![0.0; n_nodes], vec![0.0; n_nodes])
};
let mut node_color: Vec<String> = vec![tree.branch_color.clone(); n_nodes];
for &(clade_root, ref color) in &tree.clade_colors {
let mut stack = vec![clade_root];
while let Some(id) = stack.pop() {
if id < n_nodes {
node_color[id] = color.clone();
for &child in &tree.nodes[id].children {
stack.push(child);
}
}
}
}
let sw = 1.5_f64;
match tree.branch_style {
TreeBranchStyle::Slanted => {
for i in 0..n_nodes {
if let Some(p) = tree.nodes[i].parent {
scene.elements.push(Primitive::Line {
x1: px[p],
y1: py[p],
x2: px[i],
y2: py[i],
stroke: Color::from(&node_color[i]),
stroke_width: sw,
stroke_dasharray: None,
});
}
}
}
TreeBranchStyle::Rectangular => {
let horiz = matches!(
tree.orientation,
TreeOrientation::Left | TreeOrientation::Right
);
for i in 0..n_nodes {
if let Some(p) = tree.nodes[i].parent {
if horiz {
scene.elements.push(Primitive::Line {
x1: px[p],
y1: py[i],
x2: px[i],
y2: py[i],
stroke: Color::from(&node_color[i]),
stroke_width: sw,
stroke_dasharray: None,
});
} else {
scene.elements.push(Primitive::Line {
x1: px[i],
y1: py[p],
x2: px[i],
y2: py[i],
stroke: Color::from(&node_color[i]),
stroke_width: sw,
stroke_dasharray: None,
});
}
}
}
for i in 0..n_nodes {
let children = &tree.nodes[i].children;
if children.is_empty() {
continue;
}
if horiz {
let y_min = children
.iter()
.map(|&c| py[c])
.fold(f64::INFINITY, f64::min);
let y_max = children
.iter()
.map(|&c| py[c])
.fold(f64::NEG_INFINITY, f64::max);
scene.elements.push(Primitive::Line {
x1: px[i],
y1: y_min,
x2: px[i],
y2: y_max,
stroke: Color::from(&node_color[i]),
stroke_width: sw,
stroke_dasharray: None,
});
} else {
let x_min = children
.iter()
.map(|&c| px[c])
.fold(f64::INFINITY, f64::min);
let x_max = children
.iter()
.map(|&c| px[c])
.fold(f64::NEG_INFINITY, f64::max);
scene.elements.push(Primitive::Line {
x1: x_min,
y1: py[i],
x2: x_max,
y2: py[i],
stroke: Color::from(&node_color[i]),
stroke_width: sw,
stroke_dasharray: None,
});
}
}
}
TreeBranchStyle::Circular => {
let cx = eff_ml + eff_pw / 2.0;
let cy = eff_mt + eff_ph / 2.0;
for i in 0..n_nodes {
if let Some(p) = tree.nodes[i].parent {
let theta_c = theta_arr[i];
let r_p = r_arr[p];
let x1 = cx + r_p * theta_c.cos();
let y1 = cy + r_p * theta_c.sin();
scene.elements.push(Primitive::Line {
x1,
y1,
x2: px[i],
y2: py[i],
stroke: Color::from(&node_color[i]),
stroke_width: sw,
stroke_dasharray: None,
});
}
}
for i in 0..n_nodes {
let children = &tree.nodes[i].children;
if children.is_empty() {
continue;
}
let r_i = r_arr[i];
if r_i < 1.0 {
continue;
}
let thetas: Vec<f64> = children.iter().map(|&c| theta_arr[c]).collect();
let theta_min = thetas.iter().cloned().fold(f64::INFINITY, f64::min);
let theta_max = thetas.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let x_start = cx + r_i * theta_min.cos();
let y_start = cy + r_i * theta_min.sin();
let x_end = cx + r_i * theta_max.cos();
let y_end = cy + r_i * theta_max.sin();
let arc_span = theta_max - theta_min;
let large_arc = if arc_span > PI { 1 } else { 0 };
let d = format!(
"M {:.3} {:.3} A {:.3} {:.3} 0 {} 1 {:.3} {:.3}",
x_start, y_start, r_i, r_i, large_arc, x_end, y_end
);
scene.elements.push(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: Color::from(&node_color[i]),
stroke_width: sw,
opacity: None,
stroke_dasharray: None,
})));
}
}
}
scene.elements.push(Primitive::Circle {
cx: px[tree.root],
cy: py[tree.root],
r: 3.0,
fill: Color::from(&tree.branch_color),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
for &leaf in &leaves {
if let Some(ref label) = tree.nodes[leaf].label {
let (lx, ly, anchor, rotate) = match tree.branch_style {
TreeBranchStyle::Circular => {
let theta = theta_arr[leaf];
let offset = 8.0;
let lx = px[leaf] + offset * theta.cos();
let ly = py[leaf] + offset * theta.sin() + 4.0;
let anc = if theta.cos() >= 0.0 {
TextAnchor::Start
} else {
TextAnchor::End
};
(lx, ly, anc, None)
}
_ => match tree.orientation {
TreeOrientation::Left => (px[leaf] + 6.0, py[leaf], TextAnchor::Start, None),
TreeOrientation::Right => (px[leaf] - 6.0, py[leaf], TextAnchor::End, None),
TreeOrientation::Top => {
(px[leaf], py[leaf] + 6.0, TextAnchor::Start, Some(90.0))
}
TreeOrientation::Bottom => {
(px[leaf], py[leaf] - 6.0, TextAnchor::End, Some(90.0))
}
},
};
scene.elements.push(Primitive::Text {
x: lx,
y: ly,
content: label.clone(),
size: 11,
anchor,
rotate,
bold: false,
color: None,
});
}
}
if let Some(threshold) = tree.support_threshold {
for i in 0..n_nodes {
if tree.nodes[i].children.is_empty() {
continue;
}
if let Some(support) = tree.nodes[i].support {
if support >= threshold {
scene.elements.push(Primitive::Text {
x: px[i] + 2.0,
y: py[i] - 2.0,
content: format!("{}", support as u32),
size: 10,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
}
}
}
pub fn render_phylo_tree(tree: &PhyloTree, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_phylo_tree(tree, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_synteny(synteny: &SyntenyPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
if synteny.sequences.is_empty() {
return;
}
let pw = computed.plot_width();
let ph = computed.plot_height();
let ml = computed.margin_left;
let mt = computed.margin_top;
let n = synteny.sequences.len();
let max_label_chars = synteny
.sequences
.iter()
.map(|s| s.label.len())
.max()
.unwrap_or(0);
let label_pad = (max_label_chars as f64 * 7.0 + 15.0).clamp(60.0, 160.0);
let edge_pad = 20.0;
let bar_x_left = ml + label_pad + edge_pad;
let bar_x_right = ml + pw - edge_pad;
let bar_px_width = bar_x_right - bar_x_left;
let bar_h = synteny.bar_height;
let global_max = synteny
.sequences
.iter()
.map(|s| s.length)
.fold(0.0_f64, f64::max);
let gap = if n > 1 {
((ph - 2.0 * edge_pad - n as f64 * bar_h) / (n - 1) as f64).max(bar_h * 1.5)
} else {
0.0
};
let bar_top: Vec<f64> = (0..n)
.map(|i| mt + edge_pad + i as f64 * (bar_h + gap))
.collect();
let x_of = |seq_idx: usize, pos: f64| -> f64 {
let raw = if synteny.shared_scale && global_max > 0.0 {
bar_x_left + (pos / global_max) * bar_px_width
} else {
let len = synteny.sequences[seq_idx].length;
bar_x_left
+ if len > 0.0 {
(pos / len) * bar_px_width
} else {
0.0
}
};
raw.clamp(bar_x_left, bar_x_right)
};
let fallback = Palette::category10();
for (block_idx, block) in synteny.blocks.iter().enumerate() {
let (r1, s1_lo, s1_hi, r2, s2_lo, s2_hi) = if block.seq1 <= block.seq2 {
(
block.seq1,
block.start1,
block.end1,
block.seq2,
block.start2,
block.end2,
)
} else {
(
block.seq2,
block.start2,
block.end2,
block.seq1,
block.start1,
block.end1,
)
};
if r1 >= n || r2 >= n {
continue;
}
let x1_s = x_of(r1, s1_lo);
let x1_e = x_of(r1, s1_hi);
let x2_s = x_of(r2, s2_lo);
let x2_e = x_of(r2, s2_hi);
let y1_bot = bar_top[r1] + bar_h;
let y2_top = bar_top[r2];
let y_mid = (y1_bot + y2_top) / 2.0;
let is_inverted = block.strand == Strand::Reverse;
let color = block
.color
.clone()
.unwrap_or_else(|| fallback[block_idx % fallback.len()].to_string());
let d = if !is_inverted {
format!(
"M {x1_s} {y1_bot} C {x1_s} {y_mid} {x2_s} {y_mid} {x2_s} {y2_top} \
L {x2_e} {y2_top} C {x2_e} {y_mid} {x1_e} {y_mid} {x1_e} {y1_bot} Z",
x1_s = x1_s,
y1_bot = y1_bot,
y_mid = y_mid,
x2_s = x2_s,
y2_top = y2_top,
x2_e = x2_e,
x1_e = x1_e
)
} else {
format!(
"M {x1_s} {y1_bot} C {x1_s} {y_mid} {x2_e} {y_mid} {x2_e} {y2_top} \
L {x2_s} {y2_top} C {x2_s} {y_mid} {x1_e} {y_mid} {x1_e} {y1_bot} Z",
x1_s = x1_s,
y1_bot = y1_bot,
y_mid = y_mid,
x2_e = x2_e,
y2_top = y2_top,
x2_s = x2_s,
x1_e = x1_e
)
};
scene.elements.push(Primitive::Path(Box::new(PathData {
d,
fill: Some(Color::from(&color)),
stroke: color.into(),
stroke_width: 0.3,
opacity: Some(synteny.block_opacity),
stroke_dasharray: None,
})));
}
for (i, seq) in synteny.sequences.iter().enumerate() {
let bar_color = seq.color.clone().unwrap_or_else(|| "#555555".to_string());
let x_right = if synteny.shared_scale && global_max > 0.0 {
bar_x_left + (seq.length / global_max) * bar_px_width
} else {
bar_x_right
};
scene.elements.push(Primitive::Rect {
x: bar_x_left,
y: bar_top[i],
width: (x_right - bar_x_left).max(0.0),
height: bar_h,
fill: bar_color.into(),
stroke: None,
stroke_width: None,
opacity: None,
});
}
for (i, seq) in synteny.sequences.iter().enumerate() {
scene.elements.push(Primitive::Text {
x: bar_x_left - 6.0,
y: bar_top[i] + bar_h / 2.0 + 4.0,
content: seq.label.clone(),
size: computed.body_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
}
pub fn render_synteny(synteny: &SyntenyPlot, layout: &Layout) -> Scene {
let computed = ComputedLayout::from_layout(layout);
let mut scene = Scene::new(computed.width, computed.height);
scene.font_family = computed.font_family.clone();
apply_theme(&mut scene, &computed.theme);
add_labels_and_title(&mut scene, &computed, layout);
add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
add_synteny(synteny, &mut scene, &computed);
add_reference_lines(&layout.reference_lines, &mut scene, &computed);
add_text_annotations(&layout.annotations, &mut scene, &computed);
scene
}
fn add_polar(pp: &PolarPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
let pw = computed.plot_width();
let ph = computed.plot_height();
let ml = computed.margin_left;
let mt = computed.margin_top;
let cx = ml + pw / 2.0;
let cy = mt + ph / 2.0;
let label_pad = 30.0_f64;
let avail_r = (pw / 2.0 - label_pad).min(ph / 2.0 - label_pad).max(20.0);
let grid_color = Color::from(&*computed.theme.grid_color);
let axis_color = Color::from(&*computed.theme.axis_color);
let stroke_w = computed.axis_stroke_width;
let tick_sz = computed.tick_size;
let r_min = pp.r_min.unwrap_or(0.0);
let r_max = pp.r_max.unwrap_or_else(|| pp.r_max_auto());
let r_range = (r_max - r_min).max(f64::EPSILON);
let n_rings = pp.r_grid_lines.unwrap_or(4).max(1);
let n_div = pp.theta_divisions.max(2);
let theta_to_px = |r_data: f64, theta_deg: f64| -> (f64, f64) {
let r_frac = (r_data - r_min).max(0.0) / r_range;
let display_angle = pp.theta_start + theta_deg * if pp.clockwise { 1.0 } else { -1.0 };
let svg_angle = (90.0 - display_angle).to_radians();
let px = cx + r_frac * avail_r * svg_angle.cos();
let py = cy - r_frac * avail_r * svg_angle.sin(); (round2(px), round2(py))
};
if pp.show_grid {
let mut label_prims: Vec<Primitive> = Vec::new();
for i in 1..=n_rings {
let r = avail_r * (i as f64) / (n_rings as f64);
let is_outer = i == n_rings;
let (stroke, dasharray) = if is_outer {
(axis_color.clone(), None)
} else {
(grid_color.clone(), Some("4,4".to_string()))
};
let mut d = String::new();
let _ = write!(
d,
"M {},{} A {},{},0,1,0,{},{} A {},{},0,1,0,{},{} Z",
round2(cx - r),
round2(cy),
round2(r),
round2(r),
round2(cx + r),
round2(cy),
round2(r),
round2(r),
round2(cx - r),
round2(cy),
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke,
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: dasharray,
})));
if pp.show_r_labels {
let r_val = r_min + r_range * (i as f64) / (n_rings as f64);
let label = if r_val.fract() == 0.0 {
format!("{}", r_val as i64)
} else {
format!("{:.2}", r_val)
};
let mid_deg = computed
.polar_r_label_angle
.unwrap_or(360.0 / (n_div as f64 * 2.0));
let display_angle =
pp.theta_start + mid_deg * if pp.clockwise { 1.0 } else { -1.0 };
let svg_angle = (90.0 - display_angle).to_radians();
let lx = round2(cx + r * svg_angle.cos() + 2.0);
let ly = round2(cy - r * svg_angle.sin() - 2.0);
label_prims.push(Primitive::Text {
x: lx,
y: ly,
content: label,
size: tick_sz,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
if pp.show_r_labels && r_min != 0.0 {
let label = if r_min.fract() == 0.0 {
format!("{}", r_min as i64)
} else {
format!("{:.2}", r_min)
};
label_prims.push(Primitive::Text {
x: round2(cx + 4.0),
y: round2(cy - 2.0),
content: label,
size: tick_sz,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
let label_gap = 10.0_f64; let ts = tick_sz as f64;
for i in 0..n_div {
let theta_deg = i as f64 * 360.0 / n_div as f64;
let (x2, y2) = theta_to_px(r_max, theta_deg);
scene.add(Primitive::Line {
x1: round2(cx),
y1: round2(cy),
x2,
y2,
stroke: grid_color.clone(),
stroke_width: stroke_w,
stroke_dasharray: None,
});
let display_angle = pp.theta_start + theta_deg * if pp.clockwise { 1.0 } else { -1.0 };
let svg_angle = (90.0 - display_angle).to_radians();
let cos_a = svg_angle.cos();
let sin_a = svg_angle.sin();
let label_r = avail_r + label_gap;
let lx = cx + label_r * cos_a;
let ly_raw = cy - label_r * sin_a;
let ly = if sin_a > 0.15 {
ly_raw - ts * 0.2 } else if sin_a < -0.15 {
ly_raw + ts * 0.8 } else {
ly_raw + ts * 0.35 };
let anchor = if cos_a < -0.1 {
TextAnchor::End
} else if cos_a > 0.1 {
TextAnchor::Start
} else {
TextAnchor::Middle
};
label_prims.push(Primitive::Text {
x: round2(lx),
y: round2(ly),
content: computed.x_tick_format.format(theta_deg),
size: tick_sz,
anchor,
rotate: None,
bold: false,
color: None,
});
}
for prim in label_prims {
scene.add(prim);
}
}
let palette = Palette::category10();
let mut global_pt_idx: usize = 0;
for (si, series) in pp.series.iter().enumerate() {
if series.r.is_empty() {
continue;
}
let color_str = series
.color
.clone()
.unwrap_or_else(|| palette[si % palette.len()].to_string());
let color = Color::from(&*color_str);
let pts: Vec<(f64, f64)> = series
.r
.iter()
.zip(series.theta.iter())
.map(|(&r_val, &t_val)| theta_to_px(r_val, t_val))
.collect();
match series.mode {
PolarMode::Scatter => {
let r_dot = series.marker_size;
let stroke = series.marker_stroke_width.map(|_| color.clone());
for (j, (&(px, py), (&r_val, &theta_val))) in pts
.iter()
.zip(series.r.iter().zip(series.theta.iter()))
.enumerate()
{
let tip = tooltip(
pp.show_tooltips,
&pp.tooltip_labels,
global_pt_idx + j,
|| format!("r={:.2}, θ={:.1}°", r_val, theta_val),
);
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Circle {
cx: px,
cy: py,
r: r_dot,
fill: color.clone(),
fill_opacity: series.marker_opacity,
stroke: stroke.clone(),
stroke_width: series.marker_stroke_width,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
}
PolarMode::Line => {
if pts.len() < 2 {
global_pt_idx += series.r.len();
continue;
}
let path_d = build_path(&pts);
scene.add(Primitive::Path(Box::new(PathData {
d: path_d,
fill: None,
stroke: color,
stroke_width: series.stroke_width,
opacity: None,
stroke_dasharray: series.line_dash.clone(),
})));
}
}
global_pt_idx += series.r.len();
}
}
fn add_ternary(tp: &TernaryPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
let pw = computed.plot_width();
let ph = computed.plot_height();
let ml = computed.margin_left;
let mt = computed.margin_top;
let grid_color = Color::from(&*computed.theme.grid_color);
let axis_color = Color::from(&*computed.theme.axis_color);
let _text_color = Color::from(&*computed.theme.text_color);
let stroke_w = computed.axis_stroke_width;
let tick_sz = computed.tick_size;
let body_sz = computed.body_size;
let edge_pad_top = tick_sz as f64 * 2.5;
let edge_pad_bot = tick_sz as f64 * 3.5;
let edge_pad_side = tick_sz as f64 * 5.0;
let avail_w = pw - 2.0 * edge_pad_side;
let avail_h = ph - edge_pad_top - edge_pad_bot;
let side_from_h = avail_h * 2.0 / 3.0_f64.sqrt();
let side = side_from_h.min(avail_w).max(20.0);
let tri_h = side * 3.0_f64.sqrt() / 2.0;
let cx = ml + pw / 2.0;
let cy = mt + edge_pad_top + 2.0 * tri_h / 3.0;
let va = (round2(cx), round2(cy - tri_h * 2.0 / 3.0));
let vb = (round2(cx - side / 2.0), round2(cy + tri_h / 3.0));
let vc = (round2(cx + side / 2.0), round2(cy + tri_h / 3.0));
let bary_to_px = |a: f64, b: f64, c: f64| -> (f64, f64) {
let sum = a + b + c;
let (na, nb, nc) = if sum > 1e-10 {
(a / sum, b / sum, c / sum)
} else {
(1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0)
};
(
round2(na * va.0 + nb * vb.0 + nc * vc.0),
round2(na * va.1 + nb * vb.1 + nc * vc.1),
)
};
let n = tp.grid_lines.max(1);
if tp.show_grid {
for ki in 1..n {
let k = ki as f64 / n as f64;
let one_minus_k = 1.0 - k;
let (ax1, ay1) = bary_to_px(k, one_minus_k, 0.0);
let (ax2, ay2) = bary_to_px(k, 0.0, one_minus_k);
let mut d = String::new();
let _ = write!(d, "M {ax1},{ay1} L {ax2},{ay2}");
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: grid_color.clone(),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: Some("3,3".to_string()),
})));
let (bx1, by1) = bary_to_px(one_minus_k, k, 0.0);
let (bx2, by2) = bary_to_px(0.0, k, one_minus_k);
let mut d2 = String::new();
let _ = write!(d2, "M {bx1},{by1} L {bx2},{by2}");
scene.add(Primitive::Path(Box::new(PathData {
d: d2,
fill: None,
stroke: grid_color.clone(),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: Some("3,3".to_string()),
})));
let (ccx1, ccy1) = bary_to_px(one_minus_k, 0.0, k);
let (ccx2, ccy2) = bary_to_px(0.0, one_minus_k, k);
let mut d3 = String::new();
let _ = write!(d3, "M {ccx1},{ccy1} L {ccx2},{ccy2}");
scene.add(Primitive::Path(Box::new(PathData {
d: d3,
fill: None,
stroke: grid_color.clone(),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: Some("3,3".to_string()),
})));
}
}
let outline = format!(
"M {},{} L {},{} L {},{} Z",
va.0, va.1, vb.0, vb.1, vc.0, vc.1
);
scene.add(Primitive::Path(Box::new(PathData {
d: outline,
fill: None,
stroke: axis_color.clone(),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: None,
})));
if tp.show_percentages {
for ki in 0..=n {
let k = ki as f64 / n as f64;
let pct = (k * 100.0).round() as i32;
let label = format!("{}%", pct);
let (ax, ay) = bary_to_px(k, 1.0 - k, 0.0);
scene.add(Primitive::Text {
x: round2(ax - 8.0),
y: round2(ay + 4.0),
content: label.clone(),
size: tick_sz,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
let (ccx, ccy) = bary_to_px(1.0 - k, 0.0, k);
scene.add(Primitive::Text {
x: round2(ccx + 8.0),
y: round2(ccy + 4.0),
content: label.clone(),
size: tick_sz,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
let (bx, by) = bary_to_px(0.0, k, 1.0 - k);
scene.add(Primitive::Text {
x: round2(bx),
y: round2(by + 16.0),
content: label,
size: tick_sz,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
let cl_h = body_sz as f64 * 2.2; let cl_w = body_sz as f64 * 1.8; scene.add(Primitive::Text {
x: va.0,
y: round2(va.1 - cl_h),
content: tp.corner_labels[0].clone(),
size: body_sz,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
scene.add(Primitive::Text {
x: round2(vb.0 - cl_w),
y: round2(vb.1 + cl_h),
content: tp.corner_labels[1].clone(),
size: body_sz,
anchor: TextAnchor::End,
rotate: None,
bold: true,
color: None,
});
scene.add(Primitive::Text {
x: round2(vc.0 + cl_w),
y: round2(vc.1 + cl_h),
content: tp.corner_labels[2].clone(),
size: body_sz,
anchor: TextAnchor::Start,
rotate: None,
bold: true,
color: None,
});
if tp.points.is_empty() {
return;
}
let palette = Palette::category10();
let groups = tp.unique_groups();
for (tpi, pt) in tp.points.iter().enumerate() {
let (a, b, c) = if tp.normalize {
let s = pt.a + pt.b + pt.c;
if s > 1e-10 {
(pt.a / s, pt.b / s, pt.c / s)
} else {
(1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0)
}
} else {
(pt.a, pt.b, pt.c)
};
let color_str = if let Some(ref g) = pt.group {
let idx = groups.iter().position(|x| x == g).unwrap_or(0);
palette[idx % palette.len()].to_string()
} else {
palette[0].to_string()
};
let color = Color::from(&*color_str);
let stroke = tp.marker_stroke_width.map(|_| color.clone());
let (px, py) = bary_to_px(a, b, c);
let tip = tooltip(tp.show_tooltips, &tp.tooltip_labels, tpi, || {
format!("A={:.2}, B={:.2}, C={:.2}", pt.a, pt.b, pt.c)
});
if let Some(ref t) = tip {
scene.add(Primitive::GroupStart {
transform: None,
title: Some(t.clone()),
extra_attrs: None,
});
}
scene.add(Primitive::Circle {
cx: px,
cy: py,
r: tp.marker_size,
fill: color,
fill_opacity: tp.marker_opacity,
stroke,
stroke_width: tp.marker_stroke_width,
});
if tip.is_some() {
scene.add(Primitive::GroupEnd);
}
}
}
fn joint_histogram_bins(data: &[f64], lo: f64, hi: f64, n_bins: usize) -> Vec<(f64, f64, usize)> {
let n = n_bins.max(1);
let span = hi - lo;
let bw = if span > 1e-12 { span / n as f64 } else { 1.0 };
let mut counts = vec![0usize; n];
for &v in data {
if v < lo || v > hi {
continue;
}
let idx = ((v - lo) / bw).floor() as usize;
counts[idx.min(n - 1)] += 1;
}
(0..n)
.map(|i| (lo + i as f64 * bw, lo + (i + 1) as f64 * bw, counts[i]))
.collect()
}
fn joint_draw_top_marginal(
jp: &crate::plot::jointplot::JointPlot,
scatter_computed: &ComputedLayout,
panel_bottom: f64,
panel_h: f64,
scene: &mut Scene,
) {
use crate::plot::jointplot::MarginalType;
use crate::render::palette::Palette;
use render_utils::{silverman_bandwidth, simple_kde};
let (x_lo, x_hi) = scatter_computed.x_range;
let usable_h = panel_h * 0.90;
for (gi, group) in jp.groups.iter().enumerate() {
let color_str = if group.scatter.color == "black" {
Palette::category10()[gi % 10].to_string()
} else {
group.scatter.color.clone()
};
let xs = group.x_values();
match &jp.marginal_type {
MarginalType::Histogram => {
let bins = joint_histogram_bins(&xs, x_lo, x_hi, jp.bins);
let max_c = bins.iter().map(|b| b.2).max().unwrap_or(1).max(1) as f64;
for (b0, b1, count) in &bins {
if *count == 0 {
continue;
}
let px0 = scatter_computed.map_x(*b0);
let px1 = scatter_computed.map_x(*b1);
let bar_h = (*count as f64 / max_c) * usable_h;
scene.add(Primitive::Rect {
x: px0 + 0.5,
y: panel_bottom - bar_h,
width: (px1 - px0 - 1.0).max(1.0),
height: bar_h,
fill: Color::from(&*color_str),
stroke: None,
stroke_width: None,
opacity: Some(jp.marginal_alpha),
});
}
}
MarginalType::Density => {
if xs.is_empty() {
continue;
}
let bw = jp.bandwidth.unwrap_or_else(|| silverman_bandwidth(&xs));
let pts = simple_kde(&xs, bw, 200);
let in_range: Vec<_> = pts
.iter()
.filter(|(x, _)| *x >= x_lo && *x <= x_hi)
.collect();
if in_range.len() < 2 {
continue;
}
let max_d = in_range.iter().map(|(_, d)| *d).fold(0.0_f64, f64::max);
if max_d < 1e-12 {
continue;
}
let first_x = scatter_computed.map_x(in_range[0].0);
let last_x = scatter_computed.map_x(in_range[in_range.len() - 1].0);
let mut path = format!("M {first_x:.1} {panel_bottom:.1}");
for (x, d) in &in_range {
let px = scatter_computed.map_x(*x);
let py = panel_bottom - (d / max_d) * usable_h;
path.push_str(&format!(" L {px:.1} {py:.1}"));
}
path.push_str(&format!(" L {last_x:.1} {panel_bottom:.1} Z"));
scene.add(Primitive::Path(Box::new(PathData {
d: path,
fill: Some(Color::from(&*color_str)),
stroke: Color::from(&*color_str),
stroke_width: 1.5,
opacity: Some(jp.marginal_alpha),
stroke_dasharray: None,
})));
}
}
}
let x_lo_px = scatter_computed.map_x(x_lo);
let x_hi_px = scatter_computed.map_x(x_hi);
scene.add(Primitive::Line {
x1: x_lo_px,
y1: panel_bottom,
x2: x_hi_px,
y2: panel_bottom,
stroke: Color::from("#cccccc"),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
fn joint_draw_right_marginal(
jp: &crate::plot::jointplot::JointPlot,
scatter_computed: &ComputedLayout,
scatter_offset_y: f64,
panel_left: f64,
panel_w: f64,
scene: &mut Scene,
) {
use crate::plot::jointplot::MarginalType;
use crate::render::palette::Palette;
use render_utils::{silverman_bandwidth, simple_kde};
let (y_lo, y_hi) = scatter_computed.y_range;
let usable_w = panel_w * 0.90;
for (gi, group) in jp.groups.iter().enumerate() {
let color_str = if group.scatter.color == "black" {
Palette::category10()[gi % 10].to_string()
} else {
group.scatter.color.clone()
};
let ys = group.y_values();
match &jp.marginal_type {
MarginalType::Histogram => {
let bins = joint_histogram_bins(&ys, y_lo, y_hi, jp.bins);
let max_c = bins.iter().map(|b| b.2).max().unwrap_or(1).max(1) as f64;
for (b0, b1, count) in &bins {
if *count == 0 {
continue;
}
let py_bottom = scatter_offset_y + scatter_computed.map_y(*b0);
let py_top = scatter_offset_y + scatter_computed.map_y(*b1);
let bar_w = (*count as f64 / max_c) * usable_w;
let bar_h = (py_bottom - py_top - 1.0).max(1.0);
scene.add(Primitive::Rect {
x: panel_left,
y: py_top + 0.5,
width: bar_w,
height: bar_h,
fill: Color::from(&*color_str),
stroke: None,
stroke_width: None,
opacity: Some(jp.marginal_alpha),
});
}
}
MarginalType::Density => {
if ys.is_empty() {
continue;
}
let bw = jp.bandwidth.unwrap_or_else(|| silverman_bandwidth(&ys));
let pts = simple_kde(&ys, bw, 200);
let in_range: Vec<_> = pts
.iter()
.filter(|(y, _)| *y >= y_lo && *y <= y_hi)
.collect();
if in_range.len() < 2 {
continue;
}
let max_d = in_range.iter().map(|(_, d)| *d).fold(0.0_f64, f64::max);
if max_d < 1e-12 {
continue;
}
let first_py = scatter_offset_y + scatter_computed.map_y(in_range[0].0);
let last_py =
scatter_offset_y + scatter_computed.map_y(in_range[in_range.len() - 1].0);
let mut path = format!("M {panel_left:.1} {first_py:.1}");
for (y, d) in &in_range {
let py = scatter_offset_y + scatter_computed.map_y(*y);
let px = panel_left + (d / max_d) * usable_w;
path.push_str(&format!(" L {px:.1} {py:.1}"));
}
path.push_str(&format!(" L {panel_left:.1} {last_py:.1} Z"));
scene.add(Primitive::Path(Box::new(PathData {
d: path,
fill: Some(Color::from(&*color_str)),
stroke: Color::from(&*color_str),
stroke_width: 1.5,
opacity: Some(jp.marginal_alpha),
stroke_dasharray: None,
})));
}
}
}
let y_lo_px = scatter_offset_y + scatter_computed.map_y(y_lo);
let y_hi_px = scatter_offset_y + scatter_computed.map_y(y_hi);
scene.add(Primitive::Line {
x1: panel_left,
y1: y_hi_px,
x2: panel_left,
y2: y_lo_px,
stroke: Color::from("#cccccc"),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
fn add_jointplot(
jp: &crate::plot::jointplot::JointPlot,
scene: &mut Scene,
computed: &ComputedLayout,
title_offset_y: f64,
legend_width: f64,
show_legend: bool,
draw_scatter_labels: bool,
) {
use crate::render::palette::Palette;
let width = computed.width;
let height = computed.height;
let top_h = if jp.show_top { jp.marginal_size } else { 0.0 };
let right_w = if jp.show_right { jp.marginal_size } else { 0.0 };
let top_gap = if jp.show_top { jp.marginal_gap } else { 0.0 };
let right_gap = if jp.show_right { jp.marginal_gap } else { 0.0 };
let (x_min, x_max) = jp.x_range();
let (y_min, y_max) = jp.y_range();
let has_legend = jp.groups.iter().any(|g| g.scatter.legend_label.is_some());
let legend_after_right = has_legend && jp.show_right;
let legend_in_scatter = (show_legend || has_legend) && !jp.show_right;
let legend_reserve = if legend_after_right && !draw_scatter_labels {
legend_width + 10.0
} else {
0.0
};
let scatter_canvas_w = width - right_w - right_gap - legend_reserve;
let scatter_canvas_h = height - title_offset_y - top_h - top_gap;
let scatter_offset_y = title_offset_y + top_h + top_gap;
let scatter_plots: Vec<Plot> = jp
.groups
.iter()
.enumerate()
.map(|(gi, g)| {
let mut sp = g.scatter.clone();
if sp.color == "black" && g.scatter.colors.is_none() {
sp.color = Palette::category10()[gi % 10].to_string();
}
Plot::Scatter(sp)
})
.collect();
let build_scatter_layout = || {
let mut sl = Layout::new((x_min, x_max), (y_min, y_max))
.with_width(scatter_canvas_w)
.with_height(scatter_canvas_h)
.with_theme(computed.theme.clone());
if draw_scatter_labels {
if let Some(ref xl) = jp.x_label {
sl = sl.with_x_label(xl.clone());
}
if let Some(ref yl) = jp.y_label {
sl = sl.with_y_label(yl.clone());
}
}
if legend_in_scatter {
sl.show_legend = true;
}
sl
};
let scatter_computed = ComputedLayout::from_layout(&build_scatter_layout());
let scatter_scene = render_multiple(scatter_plots, build_scatter_layout());
let data_right = scatter_computed.margin_left + scatter_computed.plot_width();
if !draw_scatter_labels {
let ls = scatter_computed.label_size as f64;
if let Some(ref xl) = jp.x_label {
scene.add(Primitive::Text {
x: scatter_computed.margin_left + scatter_computed.plot_width() / 2.0,
y: computed.height - ls * 0.5,
content: xl.clone(),
size: scatter_computed.label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
if let Some(ref yl) = jp.y_label {
let yl_x = (scatter_computed.margin_left
- 8.0
- scatter_computed.y_tick_label_px
- 5.0
- ls * 0.5)
.max(ls * 0.5 + 8.0);
scene.add(Primitive::Text {
x: yl_x,
y: scatter_offset_y + scatter_canvas_h / 2.0,
content: yl.clone(),
size: scatter_computed.label_size,
anchor: TextAnchor::Middle,
rotate: Some(-90.0),
bold: false,
color: None,
});
}
}
scene.add(Primitive::GroupStart {
transform: Some(format!("translate(0,{scatter_offset_y:.1})")),
title: None,
extra_attrs: None,
});
for elem in scatter_scene.elements {
scene.elements.push(elem);
}
for def in scatter_scene.defs {
scene.defs.push(def);
}
scene.add(Primitive::GroupEnd);
if jp.show_top {
let panel_bottom = scatter_offset_y;
joint_draw_top_marginal(jp, &scatter_computed, panel_bottom, top_h, scene);
}
if jp.show_right {
let panel_left = data_right + right_gap;
joint_draw_right_marginal(
jp,
&scatter_computed,
scatter_offset_y,
panel_left,
right_w,
scene,
);
if legend_after_right {
let legend_x = panel_left + right_w + 10.0;
let line_h = scatter_computed.legend_line_height;
let pad = scatter_computed.legend_padding;
let bs = scatter_computed.body_size as f64;
let mut cur_y = scatter_offset_y + scatter_computed.margin_top + 10.0;
let entries: Vec<(String, String)> = jp
.groups
.iter()
.enumerate()
.filter_map(|(gi, g)| {
g.scatter.legend_label.as_ref().map(|lbl| {
let col = if g.scatter.color == "black" && g.scatter.colors.is_none() {
Palette::category10()[gi % 10].to_string()
} else {
g.scatter.color.clone()
};
(lbl.clone(), col)
})
})
.collect();
if !entries.is_empty() {
let box_h = entries.len() as f64 * line_h + pad * 2.0;
let legend_bg = &computed.theme.legend_bg;
let legend_border = &computed.theme.legend_border;
scene.add(Primitive::Rect {
x: legend_x - pad + 5.0,
y: cur_y - pad,
width: legend_width,
height: box_h,
fill: Color::from(&**legend_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::Rect {
x: legend_x - pad + 5.0,
y: cur_y - pad,
width: legend_width,
height: box_h,
fill: "none".into(),
stroke: Some(Color::from(&**legend_border)),
stroke_width: Some(1.0),
opacity: None,
});
for (lbl, col) in entries {
scene.add(Primitive::Circle {
cx: legend_x + 5.0,
cy: cur_y + line_h / 2.0 - 2.0,
r: 5.0,
fill: Color::from(&*col),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
scene.add(Primitive::Text {
x: legend_x + 18.0,
y: cur_y + bs * 0.8,
content: lbl,
size: scatter_computed.body_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
cur_y += line_h;
}
}
}
}
}
pub fn render_jointplot(jp: crate::plot::jointplot::JointPlot, layout: Layout) -> Scene {
let width = layout.width.unwrap_or(500.0);
let height = layout.height.unwrap_or(500.0);
let title_h = if layout.title.is_some() {
35.0_f64
} else {
0.0
};
let has_legend = jp.groups.iter().any(|g| g.scatter.legend_label.is_some());
let legend_after_right = has_legend && jp.show_right;
let legend_extra_w = if legend_after_right {
layout.legend_width + 20.0
} else {
0.0
};
let scene_width = width + legend_extra_w;
let mut scene = Scene::new(scene_width, height);
if let Some(ref font) = layout.font_family {
scene.font_family = Some(font.clone());
}
scene.background_color = Some(layout.theme.background.clone());
scene.text_color = Some(layout.theme.text_color.clone());
if let Some(ref t) = layout.title {
scene.add(Primitive::Text {
x: scene_width / 2.0,
y: title_h * 0.7,
content: t.clone(),
size: layout.title_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
let stub_layout = Layout::new((0.0, 1.0), (0.0, 1.0))
.with_width(width)
.with_height(height)
.with_theme(layout.theme.clone());
let computed = ComputedLayout::from_layout(&stub_layout);
add_jointplot(
&jp,
&mut scene,
&computed,
title_h,
layout.legend_width,
layout.show_legend,
true,
);
scene
}
pub fn render_mosaic(mp: MosaicPlot, layout: Layout) -> Scene {
let plots = vec![crate::render::plots::Plot::Mosaic(mp)];
render_multiple(plots, layout)
}
fn add_mosaic(mp: &MosaicPlot, scene: &mut Scene, computed: &ComputedLayout) {
if mp.cells.is_empty() {
return;
}
let col_order = mp.effective_col_order();
let row_order = mp.effective_row_order();
let n_cols = col_order.len();
let n_rows = row_order.len();
if n_cols == 0 || n_rows == 0 {
return;
}
let gap = mp.gap;
let body_size = computed.body_size;
let label_size = (body_size as f64 * 0.85).round() as u32;
let y_axis_w = body_size as f64 * 3.8;
let x_label_h = body_size as f64 * 2.2;
let area_left = computed.margin_left + y_axis_w;
let area_right = computed.width - computed.margin_right;
let area_top = computed.margin_top;
let area_bottom = computed.height - computed.margin_bottom - x_label_h;
let pw = (area_right - area_left).max(1.0);
let ph = (area_bottom - area_top).max(1.0);
let col_totals: Vec<f64> = col_order.iter().map(|c| mp.col_total(c)).collect();
let grand_total: f64 = col_totals.iter().sum();
if grand_total <= 0.0 {
return;
}
let usable_w = pw - (n_cols as f64 - 1.0) * gap;
let col_widths: Vec<f64> = col_totals
.iter()
.map(|&ct| (ct / grand_total) * usable_w)
.collect();
let mut col_x_starts = Vec::with_capacity(n_cols);
let mut cur_x = area_left;
for &w in &col_widths {
col_x_starts.push(cur_x);
cur_x += w + gap;
}
let theme = &computed.theme;
let axis_color: Color = Color::from(&theme.text_color);
for (ci, col_name) in col_order.iter().enumerate() {
let col_total = col_totals[ci];
if col_total <= 0.0 {
continue;
}
let col_w = col_widths[ci];
let col_x = col_x_starts[ci];
let col_h = if mp.normalize {
ph
} else {
(col_total / grand_total) * ph
};
let usable_col_h = col_h - (n_rows as f64 - 1.0) * gap;
let col_y_bottom = area_bottom;
let mut seg_y = col_y_bottom;
for (ri, row_name) in row_order.iter().enumerate().rev() {
let val = mp.cell_value(col_name, row_name);
let seg_h = if col_total > 0.0 {
(val / col_total) * usable_col_h
} else {
0.0
};
if seg_h <= 0.0 {
if ri + 1 < n_rows {
seg_y -= gap;
}
continue;
}
let seg_top = seg_y - seg_h;
let color = mp.color_for_row_idx(ri);
scene.add(Primitive::Rect {
x: col_x,
y: seg_top,
width: col_w,
height: seg_h,
fill: Color::from(color.as_str()),
stroke: None,
stroke_width: None,
opacity: None,
});
let show_label = mp.show_percents || mp.show_values;
if show_label && seg_h >= mp.min_label_height && col_w >= mp.min_label_width {
let label = if mp.show_percents && mp.show_values {
format!("{:.1}%\n{}", val / col_total * 100.0, val)
} else if mp.show_percents {
let pct = val / col_total * 100.0;
format!("{:.1}%", pct)
} else {
format!("{}", val)
};
if label.len() as f64 * label_size as f64 * 0.62 < col_w * 0.9 {
let cx = col_x + col_w / 2.0;
let cy = seg_top + seg_h / 2.0 + label_size as f64 * 0.35;
scene.add(Primitive::Text {
x: cx,
y: cy,
content: label,
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: Some(Color::from("white")),
});
}
}
seg_y = seg_top;
if ri > 0 {
seg_y -= gap;
}
}
let cx = col_x + col_w / 2.0;
let label_y = area_bottom + body_size as f64 * 1.5;
scene.add(Primitive::Text {
x: cx,
y: label_y,
content: col_name.clone(),
size: body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
let axis_x = area_left;
scene.add(Primitive::Line {
x1: axis_x,
y1: area_top,
x2: axis_x,
y2: area_bottom,
stroke: axis_color.clone(),
stroke_width: computed.axis_line_width,
stroke_dasharray: None,
});
let tick_fracs = [0.0, 0.25, 0.50, 0.75, 1.0];
let tick_len = computed.tick_mark_major;
for &frac in &tick_fracs {
let ty = area_bottom - frac * ph;
scene.add(Primitive::Line {
x1: axis_x,
y1: ty,
x2: axis_x - tick_len,
y2: ty,
stroke: axis_color.clone(),
stroke_width: computed.tick_stroke_width,
stroke_dasharray: None,
});
let pct_label = format!("{}%", (frac * 100.0) as u32);
scene.add(Primitive::Text {
x: axis_x - tick_len - computed.tick_label_margin,
y: ty + computed.tick_size as f64 * 0.35,
content: pct_label,
size: computed.tick_size,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
}
}
fn add_network(net: &NetworkPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
use std::collections::HashSet;
if net.nodes.is_empty() {
return;
}
let positions = net.compute_positions();
let font_size = net.label_size.unwrap_or(computed.body_size);
let max_label_px = if net.show_labels {
net.nodes
.iter()
.map(|n| n.label.chars().count() as f64 * 0.6 * font_size as f64 + 4.0)
.fold(0.0_f64, f64::max)
} else {
0.0
};
let r_max = net
.nodes
.iter()
.map(|n| n.size.unwrap_or(net.node_radius))
.fold(0.0_f64, f64::max);
let plot_w = computed.plot_width();
let plot_h = computed.plot_height();
let base_pad = r_max + 4.0;
let inset = 0.05;
let label_overhang = r_max + 4.0 + max_label_px;
let pad_right_extra = (label_overhang - inset * plot_w).max(0.0);
let ox = computed.margin_left + base_pad;
let oy = computed.margin_top + base_pad;
let pw = (plot_w - 2.0 * base_pad - pad_right_extra).max(1.0);
let ph = (plot_h - 2.0 * base_pad).max(1.0);
let px: Vec<f64> = positions
.iter()
.map(|(x, _)| ox + (inset + x * (1.0 - 2.0 * inset)) * pw)
.collect();
let py: Vec<f64> = positions
.iter()
.map(|(_, y)| oy + (inset + y * (1.0 - 2.0 * inset)) * ph)
.collect();
let loop_r = (r_max * 10.0).min(pw.min(ph) * 0.15);
let edge_label_size = font_size.saturating_sub(2).max(8);
let arr_len = |stroke_w: f64| stroke_w * 2.5 + 3.0;
let arrowhead = |scene: &mut Scene,
tip_x: f64,
tip_y: f64,
ux: f64,
uy: f64,
stroke_w: f64,
color: &str| {
let size = arr_len(stroke_w);
let base_x = tip_x - ux * size;
let base_y = tip_y - uy * size;
let perp_x = -uy;
let perp_y = ux;
let half_w = size * 0.4;
let d = format!(
"M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} Z",
tip_x,
tip_y,
base_x + perp_x * half_w,
base_y + perp_y * half_w,
base_x - perp_x * half_w,
base_y - perp_y * half_w
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.into()),
stroke: "none".into(),
stroke_width: 0.0,
opacity: None,
stroke_dasharray: None,
})));
};
let fallback = Palette::category10();
let mut group_map: Vec<(String, String)> = Vec::new();
{
let mut gi = 0usize;
for node in &net.nodes {
if let Some(ref g) = node.group {
if !group_map.iter().any(|(gn, _)| gn == g) {
group_map.push((g.clone(), fallback[gi % fallback.len()].to_string()));
gi += 1;
}
}
}
}
let get_color = |i: usize| -> String {
if let Some(ref c) = net.nodes[i].color {
return c.clone();
}
if let Some(ref g) = net.nodes[i].group {
if let Some(pos) = group_map.iter().position(|(gn, _)| gn == g) {
return group_map[pos].1.clone();
}
}
fallback[i % fallback.len()].to_string()
};
let (w_min, w_max) = if net.edges.is_empty() {
(0.0, 0.0)
} else {
let wn = net
.edges
.iter()
.map(|e| e.weight)
.fold(f64::INFINITY, f64::min);
let wx = net
.edges
.iter()
.map(|e| e.weight)
.fold(f64::NEG_INFINITY, f64::max);
(wn, wx)
};
let w_range = (w_max - w_min).max(1e-9);
let min_stroke = 1.0;
let max_stroke = 5.0;
let cx_graph = px.iter().sum::<f64>() / px.len() as f64;
let cy_graph = py.iter().sum::<f64>() / py.len() as f64;
let antiparallel: HashSet<(usize, usize)> = {
let mut set = HashSet::new();
let edge_set: HashSet<(usize, usize)> = net
.edges
.iter()
.filter(|e| e.source != e.target)
.map(|e| (e.source, e.target))
.collect();
for &(s, t) in &edge_set {
if edge_set.contains(&(t, s)) {
set.insert((s, t));
}
}
set
};
for edge in &net.edges {
let (si, ti) = (edge.source, edge.target);
let stroke_w = if (w_max - w_min).abs() < 1e-9 {
2.0
} else {
min_stroke + (edge.weight - w_min) / w_range * (max_stroke - min_stroke)
};
let opacity = net.edge_opacity;
let edge_color = edge.color.clone().unwrap_or_else(|| "#888888".to_string());
scene.add(Primitive::GroupStart {
transform: None,
title: None,
extra_attrs: Some(format!("opacity=\"{}\"", opacity)),
});
if si == ti {
let r = net.nodes[si].size.unwrap_or(net.node_radius);
let nx = px[si];
let ny = py[si];
let out_dx = nx - cx_graph;
let out_dy = ny - cy_graph;
let out_len = (out_dx * out_dx + out_dy * out_dy).sqrt();
let (out_ux, out_uy) = if out_len < 1e-4 {
(0.0, -1.0)
} else {
(out_dx / out_len, out_dy / out_len)
};
let perp_x = -out_uy;
let perp_y = out_ux;
let sx = nx + out_ux * r + perp_x * r * 0.5;
let sy = ny + out_uy * r + perp_y * r * 0.5;
let ex = nx + out_ux * r - perp_x * r * 0.5;
let ey = ny + out_uy * r - perp_y * r * 0.5;
let cp1x = nx + out_ux * (r + loop_r * 1.5) + perp_x * loop_r;
let cp1y = ny + out_uy * (r + loop_r * 1.5) + perp_y * loop_r;
let cp2x = nx + out_ux * (r + loop_r * 1.5) - perp_x * loop_r;
let cp2y = ny + out_uy * (r + loop_r * 1.5) - perp_y * loop_r;
let d = format!(
"M {:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {:.2}",
sx, sy, cp1x, cp1y, cp2x, cp2y, ex, ey,
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: edge_color.clone().into(),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: None,
})));
if net.directed {
let tdx = ex - cp2x;
let tdy = ey - cp2y;
let tlen = (tdx * tdx + tdy * tdy).sqrt().max(1e-6);
arrowhead(
&mut *scene,
ex,
ey,
tdx / tlen,
tdy / tlen,
stroke_w,
&edge_color,
);
}
if let Some(ref lbl) = edge.label {
let lx = (cp1x + cp2x) / 2.0;
let ly = (cp1y + cp2y) / 2.0;
scene.add(Primitive::Text {
x: round2(lx),
y: round2(ly),
content: lbl.clone(),
size: edge_label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
scene.add(Primitive::GroupEnd);
continue;
}
let (x1, y1) = (px[si], py[si]);
let (x2, y2) = (px[ti], py[ti]);
let dx = x2 - x1;
let dy = y2 - y1;
let dist = (dx * dx + dy * dy).sqrt().max(1e-6);
let ux = dx / dist;
let uy = dy / dist;
let r_src = net.nodes[si].size.unwrap_or(net.node_radius)
* net.nodes[si].shape.circumradius_factor();
let r_tgt = net.nodes[ti].size.unwrap_or(net.node_radius)
* net.nodes[ti].shape.circumradius_factor();
let is_antiparallel = net.directed && antiparallel.contains(&(si, ti));
let curve_offset = if is_antiparallel { dist * 0.15 } else { 0.0 };
if curve_offset > 0.0 {
let perp_x = -uy;
let perp_y = ux;
let mx = (x1 + x2) / 2.0 + perp_x * curve_offset;
let my = (y1 + y2) / 2.0 + perp_y * curve_offset;
let lx1 = x1 + ux * r_src;
let ly1 = y1 + uy * r_src;
let lx2 = x2 - ux * r_tgt;
let ly2 = y2 - uy * r_tgt;
if net.directed {
let arr_size = arr_len(stroke_w);
let tdx = lx2 - mx;
let tdy = ly2 - my;
let tlen = (tdx * tdx + tdy * tdy).sqrt().max(1e-6);
let tux = tdx / tlen;
let tuy = tdy / tlen;
let lx2_short = lx2 - tux * arr_size;
let ly2_short = ly2 - tuy * arr_size;
let d = format!(
"M {:.2} {:.2} Q {:.2} {:.2} {:.2} {:.2}",
lx1, ly1, mx, my, lx2_short, ly2_short
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: edge_color.clone().into(),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: None,
})));
arrowhead(&mut *scene, lx2, ly2, tux, tuy, stroke_w, &edge_color);
} else {
let d = format!(
"M {:.2} {:.2} Q {:.2} {:.2} {:.2} {:.2}",
lx1, ly1, mx, my, lx2, ly2
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: edge_color.clone().into(),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: None,
})));
}
if let Some(ref lbl) = edge.label {
let elx = (lx1 + 2.0 * mx + lx2) / 4.0 + perp_x * font_size as f64 * 0.6;
let ely = (ly1 + 2.0 * my + ly2) / 4.0 + perp_y * font_size as f64 * 0.6;
scene.add(Primitive::Text {
x: round2(elx),
y: round2(ely),
content: lbl.clone(),
size: edge_label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
} else {
let lx1 = x1 + ux * r_src;
let ly1 = y1 + uy * r_src;
let lx2 = x2 - ux * r_tgt;
let ly2 = y2 - uy * r_tgt;
if net.directed {
let arr_size = arr_len(stroke_w);
let lx2_short = lx2 - ux * arr_size;
let ly2_short = ly2 - uy * arr_size;
scene.add(Primitive::Line {
x1: round2(lx1),
y1: round2(ly1),
x2: round2(lx2_short),
y2: round2(ly2_short),
stroke: edge_color.clone().into(),
stroke_width: stroke_w,
stroke_dasharray: None,
});
arrowhead(&mut *scene, lx2, ly2, ux, uy, stroke_w, &edge_color);
} else {
scene.add(Primitive::Line {
x1: round2(lx1),
y1: round2(ly1),
x2: round2(lx2),
y2: round2(ly2),
stroke: edge_color.clone().into(),
stroke_width: stroke_w,
stroke_dasharray: None,
});
}
if let Some(ref lbl) = edge.label {
let perp_x = -uy;
let perp_y = ux;
let elx = (lx1 + lx2) / 2.0 + perp_x * font_size as f64 * 0.6;
let ely = (ly1 + ly2) / 2.0 + perp_y * font_size as f64 * 0.6;
scene.add(Primitive::Text {
x: round2(elx),
y: round2(ely),
content: lbl.clone(),
size: edge_label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
scene.add(Primitive::GroupEnd);
}
for (i, node) in net.nodes.iter().enumerate() {
let r = node.size.unwrap_or(net.node_radius);
let color = get_color(i);
match node.shape {
NodeShape::Circle => {
scene.add(Primitive::Circle {
cx: round2(px[i]),
cy: round2(py[i]),
r,
fill: color.into(),
fill_opacity: None,
stroke: Some("#ffffff".into()),
stroke_width: Some(1.5),
});
}
NodeShape::Square => {
scene.add(Primitive::Rect {
x: round2(px[i] - r),
y: round2(py[i] - r),
width: r * 2.0,
height: r * 2.0,
fill: color.into(),
stroke: Some("#ffffff".into()),
stroke_width: Some(1.5),
opacity: None,
});
}
NodeShape::Diamond => {
let d = format!(
"M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} Z",
px[i],
py[i] - r * 1.2,
px[i] + r * 1.2,
py[i],
px[i],
py[i] + r * 1.2,
px[i] - r * 1.2,
py[i],
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.into()),
stroke: "#ffffff".into(),
stroke_width: 1.5,
opacity: None,
stroke_dasharray: None,
})));
}
NodeShape::Triangle => {
let h = r * 1.4;
let d = format!(
"M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} Z",
px[i],
py[i] - h,
px[i] + h * 0.87,
py[i] + h * 0.5,
px[i] - h * 0.87,
py[i] + h * 0.5,
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(color.into()),
stroke: "#ffffff".into(),
stroke_width: 1.5,
opacity: None,
stroke_dasharray: None,
})));
}
}
}
if net.show_labels {
let n = px.len();
let cx_c = if n > 0 {
px.iter().sum::<f64>() / n as f64
} else {
0.0
};
let cy_c = if n > 0 {
py.iter().sum::<f64>() / n as f64
} else {
0.0
};
let mut labels: Vec<(f64, f64, String, f64, TextAnchor)> = net
.nodes
.iter()
.enumerate()
.map(|(i, node)| {
let r = node.size.unwrap_or(net.node_radius);
let lw = node.label.chars().count() as f64 * 0.6 * font_size as f64;
let gap = r + 4.0;
let fdx = px[i] - cx_c;
let fdy = py[i] - cy_c;
let fmag = (fdx * fdx + fdy * fdy).sqrt().max(1e-6);
let fux = fdx / fmag;
let fuy = fdy / fmag;
let (lx, anchor) = if fux > 0.25 {
(px[i] + fux * gap, TextAnchor::Start)
} else if fux < -0.25 {
(px[i] + fux * gap, TextAnchor::End)
} else {
(px[i], TextAnchor::Middle)
};
let ly = py[i] + fuy * gap + font_size as f64 * 0.35;
(lx, ly, node.label.clone(), lw, anchor)
})
.collect();
if net.repel_labels && labels.len() > 1 {
let lh = font_size as f64;
let center_x = |l: &(f64, f64, String, f64, TextAnchor)| match l.4 {
TextAnchor::Start => l.0 + l.3 / 2.0,
TextAnchor::End => l.0 - l.3 / 2.0,
TextAnchor::Middle => l.0,
};
for _ in 0..50 {
let mut moved = false;
for i in 0..labels.len() {
for j in (i + 1)..labels.len() {
let cx_i = center_x(&labels[i]);
let cx_j = center_x(&labels[j]);
let dx = cx_j - cx_i;
let dy = labels[j].1 - labels[i].1;
let overlap_x = (labels[i].3 + labels[j].3) / 2.0 - dx.abs();
let overlap_y = lh - dy.abs();
if overlap_x > 0.0 && overlap_y > 0.0 {
let push = 0.5;
if overlap_x < overlap_y {
let sign = if dx >= 0.0 { 1.0 } else { -1.0 };
labels[i].0 -= sign * overlap_x * push;
labels[j].0 += sign * overlap_x * push;
} else {
let sign = if dy >= 0.0 { 1.0 } else { -1.0 };
labels[i].1 -= sign * overlap_y * push;
labels[j].1 += sign * overlap_y * push;
}
moved = true;
}
}
}
if !moved {
break;
}
}
let x_max = ox + pw + pad_right_extra;
let y_max = oy + ph;
for l in labels.iter_mut() {
l.0 = match l.4 {
TextAnchor::Start => l.0.clamp(ox, (x_max - l.3).max(ox)),
TextAnchor::End => l.0.clamp(ox + l.3, x_max),
TextAnchor::Middle => {
l.0.clamp(ox + l.3 / 2.0, (x_max - l.3 / 2.0).max(ox + l.3 / 2.0))
}
};
l.1 = l.1.clamp(oy + font_size as f64, y_max);
}
}
for (lx, ly, text, _lw, anchor) in &labels {
scene.add(Primitive::Text {
x: round2(*lx),
y: round2(*ly),
content: text.clone(),
size: font_size,
anchor: *anchor,
rotate: None,
bold: false,
color: None,
});
}
}
}
fn add_radar(rp: &crate::plot::radar::RadarPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
use std::f64::consts::PI;
let n = rp.axes.len();
if n < 3 {
return;
}
if rp.series.is_empty() && rp.references.is_empty() {
return;
}
let pal = Palette::category10();
let theme = &computed.theme;
let plot_w = computed.plot_width();
let plot_h = computed.height - computed.margin_top - computed.margin_bottom;
let cx = computed.margin_left + plot_w / 2.0;
let cy = computed.margin_top + plot_h / 2.0;
let radius = (plot_w.min(plot_h) / 2.0) * 0.65;
let mut axis_min = vec![f64::INFINITY; n];
let mut axis_max = vec![f64::NEG_INFINITY; n];
for s in &rp.series {
for (i, &v) in s.values.iter().enumerate().take(n) {
axis_min[i] = axis_min[i].min(v);
axis_max[i] = axis_max[i].max(v);
}
if let Some(errs) = &s.errors {
for (i, (&v, &e)) in s.values.iter().zip(errs.iter()).enumerate().take(n) {
axis_min[i] = axis_min[i].min(v - e);
axis_max[i] = axis_max[i].max(v + e);
}
}
}
for i in 0..n {
if !axis_min[i].is_finite() {
axis_min[i] = 0.0;
}
if !axis_max[i].is_finite() {
axis_max[i] = 1.0;
}
if axis_min[i] >= axis_max[i] {
axis_max[i] = axis_min[i] + 1.0;
}
}
let (shared_min, shared_max) = if let Some((lo, hi)) = rp.range {
(lo, hi)
} else {
let all_min = axis_min.iter().cloned().fold(f64::INFINITY, f64::min);
let all_max = axis_max.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(all_min.min(0.0), all_max)
};
let shared_span = (shared_max - shared_min).max(f64::EPSILON);
let frac = |value: f64, ax: usize| -> f64 {
let (lo, hi) = if let Some(Some((alo, ahi))) = rp.axis_ranges.get(ax) {
(*alo, *ahi)
} else if rp.normalize {
(axis_min[ax], axis_max[ax])
} else {
(shared_min, shared_max)
};
let span = (hi - lo).max(f64::EPSILON);
let f = ((value - lo) / span).clamp(0.0, 1.0);
if rp.inverted_axes.get(ax).copied().unwrap_or(false) {
1.0 - f
} else {
f
}
};
let start_rad = rp.start_angle_deg.to_radians();
let angle = |i: usize| -> f64 { start_rad + (i as f64 * 2.0 * PI / n as f64) };
let axis_px = |i: usize, fr: f64| -> (f64, f64) {
let th = angle(i);
(
round2(cx + fr * radius * th.cos()),
round2(cy + fr * radius * th.sin()),
)
};
if rp.show_grid {
let grid_color = &theme.grid_color;
let grid_sw = computed.grid_stroke_width;
for i in 0..n {
let (ox, oy) = axis_px(i, 1.0);
scene.add(Primitive::Line {
x1: round2(cx),
y1: round2(cy),
x2: ox,
y2: oy,
stroke: Color::from(grid_color.as_str()),
stroke_width: grid_sw,
stroke_dasharray: None,
});
}
for k in 1..=rp.grid_lines {
let fr = k as f64 / rp.grid_lines as f64;
let ring_d = if rp.circular_grid {
let r = round2(fr * radius);
let cxr = round2(cx - r);
let cxl = round2(cx + r);
let cy2 = round2(cy);
format!(
"M {},{} A {},{},0,1,0,{},{} A {},{},0,1,0,{},{} Z",
cxr, cy2, r, r, cxl, cy2, r, r, cxr, cy2
)
} else {
let pts: Vec<(f64, f64)> = (0..n).map(|i| axis_px(i, fr)).collect();
radar_polygon_path(&pts)
};
scene.add(Primitive::Path(Box::new(PathData {
d: ring_d,
fill: None,
stroke: Color::from(grid_color.as_str()),
stroke_width: grid_sw,
opacity: None,
stroke_dasharray: Some("4,3".to_string()),
})));
let label_val = if rp.normalize {
format!("{:.0}%", fr * 100.0)
} else {
let v = shared_min + fr * shared_span;
if v == v.round() && v.abs() < 1e6 {
format!("{:.0}", v)
} else {
format!("{:.2}", v)
}
};
let (lx, ly) = axis_px(0, fr);
let th0 = angle(0);
let perp_off_x = -th0.sin() * 4.0 + 3.0;
let perp_off_y = th0.cos() * 4.0;
scene.add(Primitive::Text {
x: round2(lx + perp_off_x),
y: round2(ly + perp_off_y),
content: label_val,
size: (computed.tick_size as f64 * 0.8) as u32,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(Color::from(theme.tick_color.as_str())),
});
}
if rp.axis_ticks {
let tick_len = 5.0;
for i in 0..n {
let th = angle(i);
let (perp_x, perp_y) = (-th.sin(), th.cos());
for k in 1..=rp.grid_lines {
let fr = k as f64 / rp.grid_lines as f64;
let (px, py) = axis_px(i, fr);
scene.add(Primitive::Line {
x1: round2(px - perp_x * tick_len / 2.0),
y1: round2(py - perp_y * tick_len / 2.0),
x2: round2(px + perp_x * tick_len / 2.0),
y2: round2(py + perp_y * tick_len / 2.0),
stroke: Color::from(theme.tick_color.as_str()),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
}
}
}
let label_r = 1.18;
let label_size = computed.tick_size;
let line_h = label_size as f64 * 1.2;
for i in 0..n {
let th = angle(i);
let lx = cx + label_r * radius * th.cos();
let ly = cy + label_r * radius * th.sin();
let anchor = if th.cos() > 0.2 {
TextAnchor::Start
} else if th.cos() < -0.2 {
TextAnchor::End
} else {
TextAnchor::Middle
};
let base_dy = if th.sin() > 0.2 {
label_size as f64 * 0.9
} else if th.sin() < -0.2 {
0.0
} else {
label_size as f64 * 0.45
};
let lines = radar_wrap_label(&rp.axes[i], 12);
let start_y = if th.sin() < -0.2 {
round2(ly + base_dy - (lines.len() as f64 - 1.0) * line_h)
} else if th.sin() > 0.2 {
round2(ly + base_dy)
} else {
round2(ly + base_dy - (lines.len() as f64 - 1.0) * line_h / 2.0)
};
for (li, line) in lines.iter().enumerate() {
scene.add(Primitive::Text {
x: round2(lx),
y: round2(start_y + li as f64 * line_h),
content: line.clone(),
size: label_size,
anchor,
rotate: None,
bold: false,
color: None,
});
}
}
for ref_poly in &rp.references {
let ref_color = ref_poly.color.as_deref().unwrap_or("#999999");
let pts: Vec<(f64, f64)> = ref_poly
.values
.iter()
.enumerate()
.take(n)
.map(|(i, &v)| axis_px(i, frac(v, i)))
.collect();
if pts.len() < 3 {
continue;
}
scene.add(Primitive::Path(Box::new(PathData {
d: radar_polygon_path(&pts),
fill: None,
stroke: Color::from(ref_color),
stroke_width: rp.stroke_width * 0.8,
opacity: None,
stroke_dasharray: Some("6,3".to_string()),
})));
}
for (si, series) in rp.series.iter().enumerate() {
let color = series.color.clone().unwrap_or_else(|| pal[si].to_string());
if let Some(errors) = &series.errors {
let outer: Vec<(f64, f64)> = series
.values
.iter()
.enumerate()
.take(n)
.map(|(i, &v)| axis_px(i, frac(v + errors.get(i).copied().unwrap_or(0.0), i)))
.collect();
let inner: Vec<(f64, f64)> = series
.values
.iter()
.enumerate()
.take(n)
.map(|(i, &v)| axis_px(i, frac(v - errors.get(i).copied().unwrap_or(0.0), i)))
.collect();
if outer.len() >= 3 && inner.len() >= 3 {
scene.add(Primitive::Path(Box::new(PathData {
d: radar_band_path(&outer, &inner),
fill: Some(Color::from(color.as_str())),
stroke: Color::from(color.as_str()),
stroke_width: 0.5,
opacity: Some((rp.opacity * 0.6).max(0.1)),
stroke_dasharray: None,
})));
}
}
let pts: Vec<(f64, f64)> = series
.values
.iter()
.enumerate()
.take(n)
.map(|(i, &v)| axis_px(i, frac(v, i)))
.collect();
if pts.len() < 3 {
continue;
}
let path = radar_polygon_path(&pts);
if rp.filled {
scene.add(Primitive::Path(Box::new(PathData {
d: path.clone(),
fill: Some(Color::from(color.as_str())),
stroke: Color::from(color.as_str()),
stroke_width: rp.stroke_width,
opacity: Some(rp.opacity),
stroke_dasharray: series.dasharray.clone(),
})));
} else {
scene.add(Primitive::Path(Box::new(PathData {
d: path,
fill: None,
stroke: Color::from(color.as_str()),
stroke_width: rp.stroke_width,
opacity: None,
stroke_dasharray: series.dasharray.clone(),
})));
}
if let Some(r) = rp.dot_size {
for &(px, py) in &pts {
scene.add(Primitive::Circle {
cx: px,
cy: py,
r,
fill: Color::from(color.as_str()),
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
}
if rp.vertex_labels {
let label_sz = (label_size as f64 * 0.75) as u32;
let min_gap = label_sz as f64 * 1.4; let base_off = 9.0_f64;
let mut axis_items: Vec<Vec<(usize, f64, String)>> = vec![Vec::new(); n];
for (si, series) in rp.series.iter().enumerate() {
for (i, &v) in series.values.iter().enumerate().take(n) {
let radial = frac(v, i) * radius + base_off;
axis_items[i].push((si, radial, radar_fmt_value(v)));
}
}
for items in axis_items.iter_mut() {
items.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
for _ in 0..30 {
let mut moved = false;
for j in 1..items.len() {
let gap = items[j].1 - items[j - 1].1;
if gap < min_gap {
let push = (min_gap - gap) / 2.0;
items[j - 1].1 -= push;
items[j].1 += push;
moved = true;
}
}
if !moved {
break;
}
}
for item in items.iter_mut() {
item.1 = item.1.max(base_off);
}
}
for (i, items) in axis_items.iter().enumerate() {
let th = angle(i);
let anchor = if th.cos() > 0.1 {
TextAnchor::Start
} else if th.cos() < -0.1 {
TextAnchor::End
} else {
TextAnchor::Middle
};
for &(si, radial, ref text) in items {
let color = rp.series[si]
.color
.clone()
.unwrap_or_else(|| pal[si].to_string());
scene.add(Primitive::Text {
x: round2(cx + radial * th.cos()),
y: round2(cy + radial * th.sin() + label_sz as f64 * 0.35),
content: text.clone(),
size: label_sz,
anchor,
rotate: None,
bold: false,
color: Some(Color::from(color.as_str())),
});
}
}
}
}
fn radar_wrap_label(s: &str, max_chars: usize) -> Vec<String> {
if s.len() <= max_chars {
return vec![s.to_string()];
}
let words: Vec<&str> = s.split_whitespace().collect();
if words.len() < 2 {
return vec![s.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in &words {
if current.is_empty() {
current = word.to_string();
} else if current.len() + 1 + word.len() <= max_chars {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn radar_fmt_value(v: f64) -> String {
if v == v.round() && v.abs() < 1e4 {
format!("{:.0}", v)
} else if v.abs() < 10.0 {
format!("{:.2}", v)
} else {
format!("{:.1}", v)
}
}
fn radar_polygon_path(pts: &[(f64, f64)]) -> String {
if pts.is_empty() {
return String::new();
}
let mut d = format!("M {} {}", pts[0].0, pts[0].1);
for &(x, y) in &pts[1..] {
d.push_str(&format!(" L {} {}", x, y));
}
d.push_str(" Z");
d
}
fn radar_band_path(outer: &[(f64, f64)], inner: &[(f64, f64)]) -> String {
if outer.is_empty() || inner.is_empty() {
return String::new();
}
let mut d = format!("M {} {}", outer[0].0, outer[0].1);
for &(x, y) in &outer[1..] {
d.push_str(&format!(" L {} {}", x, y));
}
for &(x, y) in inner.iter().rev() {
d.push_str(&format!(" L {} {}", x, y));
}
d.push_str(" Z");
d
}
fn hexbin_cube_round(fq: f64, fr: f64) -> (i32, i32) {
let fs = -fq - fr;
let q = fq.round();
let r = fr.round();
let s = fs.round();
let dq = (q - fq).abs();
let dr = (r - fr).abs();
let ds = (s - fs).abs();
if dq > dr && dq > ds {
((-r - s) as i32, r as i32)
} else if dr > ds {
(q as i32, (-q - s) as i32)
} else {
(q as i32, r as i32)
}
}
fn hexbin_hex_path(cx: f64, cy: f64, s: f64, flat_top: bool) -> String {
use std::f64::consts::PI;
let start = if flat_top { 0.0_f64 } else { PI / 6.0 };
let mut pts = [(0.0_f64, 0.0_f64); 6];
for (i, pt) in pts.iter_mut().enumerate() {
let a = start + i as f64 * PI / 3.0;
*pt = (round2(cx + s * a.cos()), round2(cy + s * a.sin()));
}
let mut d = format!("M {} {}", pts[0].0, pts[0].1);
for &(x, y) in &pts[1..] {
d.push_str(&format!(" L {} {}", x, y));
}
d.push_str(" Z");
d
}
fn hexbin_bin_values(hb: &HexbinPlot, computed: &ComputedLayout) -> Vec<((i32, i32), f64)> {
use std::collections::HashMap;
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let plot_top = computed.margin_top;
let plot_bottom = computed.height - computed.margin_bottom;
let plot_w = plot_right - plot_left;
let plot_h = plot_bottom - plot_top;
if plot_w <= 0.0 || plot_h <= 0.0 {
return vec![];
}
let n_x = hb.n_bins.max(2) as f64;
let s_px = hb.bin_size.unwrap_or(if hb.flat_top {
plot_w / (n_x * 1.5)
} else {
plot_w / (n_x * 3_f64.sqrt())
});
if s_px <= 0.0 {
return vec![];
}
let mut bins: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
for (idx, (&xi, &yi)) in hb.x.iter().zip(hb.y.iter()).enumerate() {
if let Some((lo, hi)) = hb.x_range {
if xi < lo || xi > hi {
continue;
}
}
if let Some((lo, hi)) = hb.y_range {
if yi < lo || yi > hi {
continue;
}
}
let px = computed.map_x(xi);
let py = computed.map_y(yi);
if px < plot_left - s_px || px > plot_right + s_px {
continue;
}
if py < plot_top - s_px || py > plot_bottom + s_px {
continue;
}
let hx = (px - plot_left) / s_px;
let hy = (py - plot_top) / s_px;
let (q, r) = if hb.flat_top {
hexbin_cube_round(2.0 / 3.0 * hx, -1.0 / 3.0 * hx + 3_f64.sqrt() / 3.0 * hy)
} else {
hexbin_cube_round(3_f64.sqrt() / 3.0 * hx - 1.0 / 3.0 * hy, 2.0 / 3.0 * hy)
};
bins.entry((q, r)).or_default().push(idx);
}
if bins.is_empty() {
return vec![];
}
let total_pts = hb.x.len() as f64;
let min_count = hb.min_count.max(1);
let mut result: Vec<((i32, i32), f64)> = bins
.iter()
.filter(|(_, pts)| pts.len() >= min_count)
.map(|(&key, pts)| {
let val = match &hb.z_reduce {
ZReduce::Count => {
if hb.normalize {
pts.len() as f64 / total_pts
} else {
pts.len() as f64
}
}
ZReduce::Mean => {
hb.z.as_ref()
.map(|z| pts.iter().map(|&i| z[i]).sum::<f64>() / pts.len() as f64)
.unwrap_or(pts.len() as f64)
}
ZReduce::Sum => {
hb.z.as_ref()
.map(|z| pts.iter().map(|&i| z[i]).sum::<f64>())
.unwrap_or(pts.len() as f64)
}
ZReduce::Min => {
hb.z.as_ref()
.map(|z| pts.iter().map(|&i| z[i]).fold(f64::INFINITY, f64::min))
.unwrap_or(pts.len() as f64)
}
ZReduce::Max => {
hb.z.as_ref()
.map(|z| pts.iter().map(|&i| z[i]).fold(f64::NEG_INFINITY, f64::max))
.unwrap_or(pts.len() as f64)
}
ZReduce::Median => {
hb.z.as_ref()
.map(|z| {
let mut vals: Vec<f64> = pts.iter().map(|&i| z[i]).collect();
vals.sort_by(|a, b| a.total_cmp(b));
let mid = vals.len() / 2;
if vals.len().is_multiple_of(2) {
(vals[mid - 1] + vals[mid]) / 2.0
} else {
vals[mid]
}
})
.unwrap_or(pts.len() as f64)
}
};
(key, val)
})
.collect();
result.sort_by_key(|&((q, r), _)| (q, r));
result
}
fn add_hexbin_colorbar(hb: &HexbinPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::legend::ColorBarInfo;
use std::sync::Arc;
if !hb.show_colorbar {
return;
}
let bin_vals = hexbin_bin_values(hb, computed);
if bin_vals.is_empty() {
return;
}
let (v_min_raw, v_max_raw) = bin_vals
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, v)| {
(lo.min(*v), hi.max(*v))
});
let (v_min, v_max) = hb.color_range.unwrap_or((v_min_raw, v_max_raw));
let cmap = hb.color_map.clone();
let cb_label = hb
.colorbar_label
.clone()
.unwrap_or_else(|| match hb.z_reduce {
ZReduce::Count if hb.normalize => "Density".to_string(),
ZReduce::Count => "Count".to_string(),
ZReduce::Mean => "Mean".to_string(),
ZReduce::Sum => "Sum".to_string(),
ZReduce::Median => "Median".to_string(),
ZReduce::Min => "Min".to_string(),
ZReduce::Max => "Max".to_string(),
});
type MapFn = Arc<dyn Fn(f64) -> String + Send + Sync>;
#[allow(clippy::type_complexity)]
let (map_min, map_max, cb_map_fn, tick_labels): (
f64,
f64,
MapFn,
Option<Vec<(f64, String)>>,
) = if hb.log_color {
let log_max = (v_max - v_min + 1.0).max(1.0).log10().max(f64::EPSILON);
let mut ticks = vec![(0.0_f64, "0".to_string())];
let mut k = 0u32;
loop {
let count = 10_f64.powi(k as i32);
if count > v_max - v_min {
break;
}
ticks.push(((count + 1.0).log10(), format!("{}", count as u64)));
k += 1;
}
ticks.push((log_max, format!("{}", (v_max - v_min) as u64)));
ticks.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9);
let lmax = log_max;
(
0.0,
lmax,
Arc::new(move |t: f64| cmap.map((t / lmax).clamp(0.0, 1.0))),
Some(ticks),
)
} else {
let span = (v_max - v_min).max(f64::EPSILON);
let cmin = v_min;
(
v_min,
v_max,
Arc::new(move |t: f64| cmap.map(((t - cmin) / span).clamp(0.0, 1.0))),
None,
)
};
let cb_info = ColorBarInfo {
map_fn: cb_map_fn,
min_value: map_min,
max_value: map_max,
label: Some(cb_label),
tick_labels,
};
add_colorbar(&cb_info, scene, computed);
}
fn add_hexbin(hb: &HexbinPlot, scene: &mut Scene, computed: &ComputedLayout) {
if hb.x.is_empty() {
return;
}
let plot_left = computed.margin_left;
let plot_right = computed.width - computed.margin_right;
let plot_top = computed.margin_top;
let plot_bottom = computed.height - computed.margin_bottom;
let plot_w = plot_right - plot_left;
let plot_h = plot_bottom - plot_top;
if plot_w <= 0.0 || plot_h <= 0.0 {
return;
}
let n_x = hb.n_bins.max(2) as f64;
let s_px = hb.bin_size.unwrap_or(if hb.flat_top {
plot_w / (n_x * 1.5)
} else {
plot_w / (n_x * 3_f64.sqrt())
});
if s_px <= 0.0 {
return;
}
let bin_vals = hexbin_bin_values(hb, computed);
if bin_vals.is_empty() {
return;
}
if bin_vals.is_empty() {
return;
}
let (v_min_raw, v_max_raw) = bin_vals
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, v)| {
(lo.min(*v), hi.max(*v))
});
let (v_min, v_max) = hb.color_range.unwrap_or((v_min_raw, v_max_raw));
let v_span = (v_max - v_min).max(f64::EPSILON);
let log_max = if hb.log_color {
(v_max - v_min + 1.0).max(1.0).log10().max(f64::EPSILON)
} else {
1.0
};
let color_for = |v: f64| -> String {
let norm = if hb.log_color {
((v - v_min + 1.0).max(1.0).log10() / log_max).clamp(0.0, 1.0)
} else {
((v - v_min) / v_span).clamp(0.0, 1.0)
};
hb.color_map.map(norm)
};
for &((q, r), val) in &bin_vals {
let (cx, cy) = if hb.flat_top {
(
plot_left + s_px * (1.5 * q as f64),
plot_top + s_px * (3_f64.sqrt() / 2.0 * q as f64 + 3_f64.sqrt() * r as f64),
)
} else {
(
plot_left + s_px * (3_f64.sqrt() * q as f64 + 3_f64.sqrt() / 2.0 * r as f64),
plot_top + s_px * (1.5 * r as f64),
)
};
if cx < plot_left - 2.0 * s_px || cx > plot_right + 2.0 * s_px {
continue;
}
if cy < plot_top - 2.0 * s_px || cy > plot_bottom + 2.0 * s_px {
continue;
}
let fill_color = color_for(val);
let (stroke_color, stroke_w) = if let Some(ref sc) = hb.stroke_color {
(sc.as_str().to_string(), hb.stroke_width)
} else {
(fill_color.clone(), 0.0)
};
scene.add(Primitive::Path(Box::new(PathData {
d: hexbin_hex_path(cx, cy, s_px, hb.flat_top),
fill: Some(Color::from(fill_color.as_str())),
stroke: Color::from(stroke_color.as_str()),
stroke_width: stroke_w,
opacity: None,
stroke_dasharray: None,
})));
}
}
#[derive(Clone, Copy, Debug)]
struct TmRect {
x: f64,
y: f64,
w: f64,
h: f64,
}
impl TmRect {
#[inline]
fn area(self) -> f64 {
self.w * self.h
}
}
struct Tile {
label: String,
value: f64,
color_value: Option<f64>,
inherited_color: Option<String>,
explicit_color: Option<String>,
rect: TmRect,
depth: usize,
path: String,
is_leaf: bool,
}
#[inline]
fn worst_ratio(row: &[f64], w: f64) -> f64 {
let s: f64 = row.iter().sum();
if s <= f64::EPSILON || w <= f64::EPSILON {
return f64::MAX;
}
let max_v = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min_v = row.iter().cloned().fold(f64::INFINITY, f64::min);
if min_v <= f64::EPSILON {
return f64::MAX;
}
((w * w * max_v) / (s * s)).max((s * s) / (w * w * min_v))
}
#[inline]
fn tm_effective_padding(base: f64, depth: usize) -> f64 {
(base / (1u64 << depth.min(10)) as f64).max(1.0)
}
fn run_squarify(pixel_areas: &[f64], rect: TmRect) -> Vec<(usize, TmRect)> {
if rect.w <= 0.0 || rect.h <= 0.0 {
return vec![];
}
let mut order: Vec<usize> = (0..pixel_areas.len())
.filter(|&i| pixel_areas[i] > f64::EPSILON)
.collect();
order.sort_by(|&a, &b| {
pixel_areas[b]
.partial_cmp(&pixel_areas[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut result = vec![
TmRect {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0
};
pixel_areas.len()
];
squarify_recursive(&order, pixel_areas, rect, &mut result);
order.iter().map(|&i| (i, result[i])).collect()
}
fn squarify_recursive(order: &[usize], areas: &[f64], rect: TmRect, result: &mut Vec<TmRect>) {
if order.is_empty() || rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
if order.len() == 1 {
result[order[0]] = rect;
return;
}
let w = rect.w.min(rect.h);
let mut row_len = 1;
let mut row_vals: Vec<f64> = vec![areas[order[0]]];
let mut prev_ratio = worst_ratio(&row_vals, w);
for k in 1..order.len() {
let mut candidate = row_vals.clone();
candidate.push(areas[order[k]]);
let new_ratio = worst_ratio(&candidate, w);
if new_ratio <= prev_ratio {
row_vals = candidate;
row_len += 1;
prev_ratio = new_ratio;
} else {
break;
}
}
let row_sum: f64 = row_vals.iter().sum();
let strip_size = if rect.w >= rect.h {
row_sum / rect.w
} else {
row_sum / rect.h
};
let strip_size = strip_size.max(0.0);
let mut offset = 0.0_f64;
for &idx in order[..row_len].iter() {
let frac = if row_sum > f64::EPSILON {
areas[idx] / row_sum
} else {
1.0 / row_len as f64
};
result[idx] = if rect.w >= rect.h {
let rw = (frac * rect.w).max(0.0);
let r = TmRect {
x: rect.x + offset,
y: rect.y,
w: rw,
h: strip_size,
};
offset += rw;
r
} else {
let rh = (frac * rect.h).max(0.0);
let r = TmRect {
x: rect.x,
y: rect.y + offset,
w: strip_size,
h: rh,
};
offset += rh;
r
};
}
let remaining = if rect.w >= rect.h {
TmRect {
x: rect.x,
y: rect.y + strip_size,
w: rect.w,
h: (rect.h - strip_size).max(0.0),
}
} else {
TmRect {
x: rect.x + strip_size,
y: rect.y,
w: (rect.w - strip_size).max(0.0),
h: rect.h,
}
};
squarify_recursive(&order[row_len..], areas, remaining, result);
}
fn run_slicedice(pixel_areas: &[f64], rect: TmRect, depth: usize) -> Vec<(usize, TmRect)> {
if rect.w <= 0.0 || rect.h <= 0.0 {
return vec![];
}
let total: f64 = pixel_areas.iter().sum();
if total <= f64::EPSILON {
return vec![];
}
let horizontal = depth.is_multiple_of(2);
let mut offset = 0.0_f64;
pixel_areas
.iter()
.enumerate()
.filter(|(_, &a)| a > f64::EPSILON)
.map(|(i, &a)| {
let frac = a / total;
let r = if horizontal {
let w = (frac * rect.w).max(0.0);
let r = TmRect {
x: rect.x + offset,
y: rect.y,
w,
h: rect.h,
};
offset += w;
r
} else {
let h = (frac * rect.h).max(0.0);
let r = TmRect {
x: rect.x,
y: rect.y + offset,
w: rect.w,
h,
};
offset += h;
r
};
(i, r)
})
.collect()
}
fn run_binary(pixel_areas: &[f64], rect: TmRect, depth: usize) -> Vec<(usize, TmRect)> {
if rect.w <= 0.0 || rect.h <= 0.0 {
return vec![];
}
let mut order: Vec<usize> = (0..pixel_areas.len())
.filter(|&i| pixel_areas[i] > f64::EPSILON)
.collect();
if order.is_empty() {
return vec![];
}
order.sort_by(|&a, &b| {
pixel_areas[b]
.partial_cmp(&pixel_areas[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut result = vec![
TmRect {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0
};
pixel_areas.len()
];
binary_recursive(&order, pixel_areas, rect, depth, &mut result);
order.iter().map(|&i| (i, result[i])).collect()
}
fn binary_recursive(
order: &[usize],
areas: &[f64],
rect: TmRect,
depth: usize,
result: &mut Vec<TmRect>,
) {
if order.is_empty() || rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
if order.len() == 1 {
result[order[0]] = rect;
return;
}
let total: f64 = order.iter().map(|&i| areas[i]).sum();
let mut best_diff = f64::MAX;
let mut split = 1;
let mut cum = 0.0_f64;
for k in 0..order.len() - 1 {
cum += areas[order[k]];
let diff = (cum - (total - cum)).abs();
if diff < best_diff {
best_diff = diff;
split = k + 1;
}
}
let left_sum: f64 = order[..split].iter().map(|&i| areas[i]).sum();
let horizontal = depth.is_multiple_of(2);
let (left_rect, right_rect) = if horizontal {
let lw = (left_sum / total * rect.w).max(0.0);
(
TmRect {
x: rect.x,
y: rect.y,
w: lw,
h: rect.h,
},
TmRect {
x: rect.x + lw,
y: rect.y,
w: (rect.w - lw).max(0.0),
h: rect.h,
},
)
} else {
let lh = (left_sum / total * rect.h).max(0.0);
(
TmRect {
x: rect.x,
y: rect.y,
w: rect.w,
h: lh,
},
TmRect {
x: rect.x,
y: rect.y + lh,
w: rect.w,
h: (rect.h - lh).max(0.0),
},
)
};
binary_recursive(&order[..split], areas, left_rect, depth + 1, result);
binary_recursive(&order[split..], areas, right_rect, depth + 1, result);
}
fn tm_layout(
pixel_areas: &[f64],
rect: TmRect,
depth: usize,
algo: &TreemapLayout,
) -> Vec<(usize, TmRect)> {
match algo {
TreemapLayout::Squarify => run_squarify(pixel_areas, rect),
TreemapLayout::SliceDice => run_slicedice(pixel_areas, rect, depth),
TreemapLayout::Binary => run_binary(pixel_areas, rect, depth),
}
}
#[allow(clippy::too_many_arguments)]
fn collect_treemap_tiles(
nodes: &[TreemapNode],
rect: TmRect,
depth: usize,
tm: &TreemapPlot,
inherited_color: Option<&str>,
path_prefix: &str,
leaf_idx: &mut usize,
tiles: &mut Vec<Tile>,
) {
let font_size = 12.0_f64;
let active: Vec<&TreemapNode> = nodes
.iter()
.filter(|n| n.resolved_value() > f64::EPSILON)
.collect();
if active.is_empty() || rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let total: f64 = active.iter().map(|n| n.resolved_value()).sum();
let pixel_areas: Vec<f64> = active
.iter()
.map(|n| n.resolved_value() / total * rect.area())
.collect();
let rects = tm_layout(&pixel_areas, rect, depth, &tm.layout_algo);
for (i, tile_rect) in rects {
let node = active[i];
let max_depth_reached = tm.max_depth.map(|md| depth >= md).unwrap_or(false);
let is_leaf = node.children.is_empty() || max_depth_reached;
let color_value = if node.children.is_empty() {
let cv = tm
.color_values
.as_ref()
.and_then(|cv| cv.get(*leaf_idx).copied());
*leaf_idx += 1;
cv
} else {
None
};
let path = if path_prefix.is_empty() {
node.label.clone()
} else {
format!("{} > {}", path_prefix, node.label)
};
tiles.push(Tile {
label: node.label.clone(),
value: node.resolved_value(),
color_value,
inherited_color: inherited_color.map(|s| s.to_string()),
explicit_color: node.color.clone(),
rect: tile_rect,
depth,
path: path.clone(),
is_leaf,
});
if !node.children.is_empty() && !max_depth_reached {
let pad = tm_effective_padding(tm.padding, depth);
let label_reserve = if tm.show_parent_labels {
font_size * 1.4
} else {
0.0
};
let child_rect = TmRect {
x: tile_rect.x + pad,
y: tile_rect.y + pad + label_reserve,
w: (tile_rect.w - 2.0 * pad).max(0.0),
h: (tile_rect.h - 2.0 * pad - label_reserve).max(0.0),
};
if child_rect.w > 0.0 && child_rect.h > 0.0 {
collect_treemap_tiles(
&node.children,
child_rect,
depth + 1,
tm,
inherited_color,
&path,
leaf_idx,
tiles,
);
}
}
}
}
fn treemap_leaf_values(roots: &[TreemapNode]) -> Vec<f64> {
fn recurse(nodes: &[TreemapNode], out: &mut Vec<f64>) {
for n in nodes {
if n.children.is_empty() {
out.push(n.resolved_value());
} else {
recurse(&n.children, out);
}
}
}
let mut out = Vec::new();
recurse(roots, &mut out);
out
}
fn compute_treemap_value_range(tm: &TreemapPlot) -> (f64, f64) {
if let Some(range) = tm.color_range {
return range;
}
let vals = if let Some(ref cv) = tm.color_values {
cv.clone()
} else {
treemap_leaf_values(&tm.roots)
};
if vals.is_empty() {
return (0.0, 1.0);
}
let lo = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let hi = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(lo, hi)
}
fn add_treemap(tm: &TreemapPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
let plot_rect = TmRect {
x: computed.margin_left,
y: computed.margin_top,
w: (computed.width - computed.margin_left - computed.margin_right).max(0.0),
h: (computed.height - computed.margin_top - computed.margin_bottom).max(0.0),
};
if plot_rect.w <= 0.0 || plot_rect.h <= 0.0 {
return;
}
let cat10 = Palette::category10();
let active_roots: Vec<&TreemapNode> = tm
.roots
.iter()
.filter(|n| n.resolved_value() > f64::EPSILON)
.collect();
if active_roots.is_empty() {
return;
}
let total_root: f64 = active_roots.iter().map(|n| n.resolved_value()).sum();
let root_areas: Vec<f64> = active_roots
.iter()
.map(|n| n.resolved_value() / total_root * plot_rect.area())
.collect();
let root_rects = tm_layout(&root_areas, plot_rect, 0, &tm.layout_algo);
let mut tiles: Vec<Tile> = Vec::new();
let mut leaf_idx = 0usize;
for (i, root_rect) in root_rects {
let node = active_roots[i];
let root_color = cat10[i % cat10.len()].to_string();
let max_depth_reached = tm.max_depth.map(|md| md == 0).unwrap_or(false);
let is_leaf = node.children.is_empty() || max_depth_reached;
let color_value = if node.children.is_empty() {
let cv = tm
.color_values
.as_ref()
.and_then(|cv| cv.get(leaf_idx).copied());
leaf_idx += 1;
cv
} else {
None
};
let path = node.label.clone();
tiles.push(Tile {
label: node.label.clone(),
value: node.resolved_value(),
color_value,
inherited_color: Some(root_color.clone()),
explicit_color: node.color.clone(),
rect: root_rect,
depth: 0,
path: path.clone(),
is_leaf,
});
if !node.children.is_empty() && !max_depth_reached {
let pad = tm_effective_padding(tm.padding, 0);
let label_reserve = if tm.show_parent_labels {
12.0 * 1.4
} else {
0.0
};
let child_rect = TmRect {
x: root_rect.x + pad,
y: root_rect.y + pad + label_reserve,
w: (root_rect.w - 2.0 * pad).max(0.0),
h: (root_rect.h - 2.0 * pad - label_reserve).max(0.0),
};
if child_rect.w > 0.0 && child_rect.h > 0.0 {
collect_treemap_tiles(
&node.children,
child_rect,
1,
tm,
Some(&root_color),
&path,
&mut leaf_idx,
&mut tiles,
);
}
}
}
let (v_min, v_max) = if matches!(tm.color_mode, TreemapColorMode::ByValue(_)) {
compute_treemap_value_range(tm)
} else {
(0.0, 1.0)
};
let v_span = (v_max - v_min).max(f64::EPSILON);
let font_size = 12u32;
for tile in &tiles {
let fill_color = match &tm.color_mode {
TreemapColorMode::ByParent => tile
.inherited_color
.as_deref()
.unwrap_or("#888888")
.to_string(),
TreemapColorMode::ByValue(cmap) => {
if tile.is_leaf {
let raw = tile.color_value.unwrap_or(tile.value);
let norm = ((raw - v_min) / v_span).clamp(0.0, 1.0);
cmap.map(norm)
} else {
"#e0e0e0".to_string()
}
}
TreemapColorMode::Explicit => tile
.explicit_color
.as_deref()
.unwrap_or("#888888")
.to_string(),
};
let stroke_w = if tile.depth == 0 {
tm.root_border_width
} else {
tm.border_width
};
if tm.show_tooltips {
let tooltip_text = format!("{}\n{:.4}", tile.path, tile.value);
scene.add(Primitive::GroupStart {
transform: None,
title: Some(tooltip_text),
extra_attrs: None,
});
}
scene.add(Primitive::Rect {
x: round2(tile.rect.x),
y: round2(tile.rect.y),
width: round2(tile.rect.w.max(0.0)),
height: round2(tile.rect.h.max(0.0)),
fill: Color::from(fill_color.as_str()),
stroke: Some(Color::from("#ffffff")),
stroke_width: Some(stroke_w),
opacity: None,
});
let area = tile.rect.area();
if area >= tm.min_label_area && tile.rect.w.min(tile.rect.h) >= font_size as f64 * 1.5 {
let is_parent_label = !tile.is_leaf;
let show_lbl = if is_parent_label {
tm.show_parent_labels
} else {
tm.show_labels
};
if show_lbl {
let char_w_est = font_size as f64 * 0.55;
let max_chars = ((tile.rect.w * 0.88) / char_w_est).floor() as usize;
let label = if max_chars > 2 && tile.label.chars().count() > max_chars {
let truncated: String = tile
.label
.chars()
.take(max_chars.saturating_sub(1))
.collect();
format!("{}…", truncated)
} else {
tile.label.clone()
};
let (lx, ly, anchor, bold) = if is_parent_label {
(
tile.rect.x + 4.0,
tile.rect.y + font_size as f64 + 2.0,
TextAnchor::Start,
true,
)
} else {
(
tile.rect.x + tile.rect.w * 0.5,
tile.rect.y + tile.rect.h * 0.5 + font_size as f64 * 0.35,
TextAnchor::Middle,
false,
)
};
scene.add(Primitive::Text {
x: round2(lx),
y: round2(ly),
content: label,
size: font_size,
anchor,
rotate: None,
bold,
color: Some(Color::from("#ffffff")),
});
}
}
if tm.show_tooltips {
scene.add(Primitive::GroupEnd);
}
}
}
fn add_treemap_colorbar(tm: &TreemapPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::legend::ColorBarInfo;
use std::sync::Arc;
let cmap = match &tm.color_mode {
TreemapColorMode::ByValue(cmap) => cmap.clone(),
_ => return,
};
if !tm.show_colorbar {
return;
}
let (v_min, v_max) = compute_treemap_value_range(tm);
let span = (v_max - v_min).max(f64::EPSILON);
let cmin = v_min;
let label = tm
.colorbar_label
.clone()
.unwrap_or_else(|| "Value".to_string());
let cb_info = ColorBarInfo {
map_fn: Arc::new(move |t: f64| cmap.map(((t - cmin) / span).clamp(0.0, 1.0))),
min_value: v_min,
max_value: v_max,
label: Some(label),
tick_labels: None,
};
add_colorbar(&cb_info, scene, computed);
}
pub fn render_treemap(tm: TreemapPlot, layout: Layout) -> Scene {
let plots = vec![Plot::Treemap(tm)];
render_multiple(plots, layout)
}
struct SbArc {
label: String,
value: f64,
color_value: Option<f64>,
inherited_color: Option<String>,
explicit_color: Option<String>,
start_deg: f64,
sweep_deg: f64,
r_inner: f64,
r_outer: f64,
path: String,
is_leaf: bool,
}
#[inline]
fn compass_rad(deg: f64) -> f64 {
(deg - 90.0_f64).to_radians()
}
#[inline]
fn arc_point(cx: f64, cy: f64, r: f64, deg_compass: f64) -> (f64, f64) {
let a = compass_rad(deg_compass);
(cx + r * a.cos(), cy + r * a.sin())
}
fn sunburst_arc_path(
cx: f64,
cy: f64,
r_inner: f64,
r_outer: f64,
start_deg: f64,
sweep_deg: f64,
) -> String {
let end_deg = start_deg + sweep_deg;
if sweep_deg.abs() >= 359.9 {
let (ox1, oy1) = arc_point(cx, cy, r_outer, start_deg);
let (ox2, oy2) = arc_point(cx, cy, r_outer, start_deg + 180.0);
if r_inner <= 0.5 {
return format!(
"M{cx},{cy} L{ox1},{oy1} A{ro},{ro} 0 1,1 {ox2},{oy2} A{ro},{ro} 0 1,1 {ox1},{oy1} Z",
ro = r_outer, cx = round2(cx), cy = round2(cy),
ox1 = round2(ox1), oy1 = round2(oy1),
ox2 = round2(ox2), oy2 = round2(oy2),
);
} else {
let (ix1, iy1) = arc_point(cx, cy, r_inner, start_deg);
let (ix2, iy2) = arc_point(cx, cy, r_inner, start_deg + 180.0);
return format!(
"M{ox1},{oy1} A{ro},{ro} 0 1,1 {ox2},{oy2} A{ro},{ro} 0 1,1 {ox1},{oy1} \
M{ix1},{iy1} A{ri},{ri} 0 1,0 {ix2},{iy2} A{ri},{ri} 0 1,0 {ix1},{iy1} Z",
ro = r_outer,
ri = r_inner,
ox1 = round2(ox1),
oy1 = round2(oy1),
ox2 = round2(ox2),
oy2 = round2(oy2),
ix1 = round2(ix1),
iy1 = round2(iy1),
ix2 = round2(ix2),
iy2 = round2(iy2),
);
}
}
let large_arc = if sweep_deg.abs() > 180.0 { 1 } else { 0 };
let (ox1, oy1) = arc_point(cx, cy, r_outer, start_deg);
let (ox2, oy2) = arc_point(cx, cy, r_outer, end_deg);
if r_inner <= 0.5 {
format!(
"M{cx},{cy} L{ox1},{oy1} A{ro},{ro} 0 {la},1 {ox2},{oy2} Z",
ro = r_outer,
la = large_arc,
cx = round2(cx),
cy = round2(cy),
ox1 = round2(ox1),
oy1 = round2(oy1),
ox2 = round2(ox2),
oy2 = round2(oy2),
)
} else {
let (ix1, iy1) = arc_point(cx, cy, r_inner, start_deg);
let (ix2, iy2) = arc_point(cx, cy, r_inner, end_deg);
format!(
"M{ox1},{oy1} A{ro},{ro} 0 {la},1 {ox2},{oy2} L{ix2},{iy2} A{ri},{ri} 0 {la},0 {ix1},{iy1} Z",
ro = r_outer, ri = r_inner, la = large_arc,
ox1 = round2(ox1), oy1 = round2(oy1),
ox2 = round2(ox2), oy2 = round2(oy2),
ix1 = round2(ix1), iy1 = round2(iy1),
ix2 = round2(ix2), iy2 = round2(iy2),
)
}
}
fn build_sunburst_arcs(sb: &SunburstPlot, avail_r: f64) -> Vec<SbArc> {
use crate::render::palette::Palette;
let cat10 = Palette::category10();
let active_roots: Vec<&TreemapNode> = sb
.roots
.iter()
.filter(|n| n.resolved_value() > f64::EPSILON)
.collect();
if active_roots.is_empty() {
return vec![];
}
let max_tree_depth = sb.max_tree_depth();
let n_rings = if let Some(md) = sb.max_depth {
(md + 1).min(max_tree_depth + 1)
} else {
max_tree_depth + 1
};
let n_rings = n_rings.max(1);
let ring_w_total = (1.0 - sb.inner_radius_frac) * avail_r;
let ring_w = ring_w_total / n_rings as f64;
let total_root: f64 = active_roots.iter().map(|n| n.resolved_value()).sum();
let cv_slice: &[f64] = sb.color_values.as_deref().unwrap_or(&[]);
let mut arcs: Vec<SbArc> = Vec::new();
struct PendingNode<'a> {
node: &'a TreemapNode,
start_deg: f64,
sweep_deg: f64,
inherited_color: Option<String>,
}
let mut pending: Vec<PendingNode> = Vec::new();
let mut leaf_idx = 0usize;
let mut cursor = sb.start_angle_deg;
for (i, node) in active_roots.iter().enumerate() {
let val = node.resolved_value();
let sweep = val / total_root * 360.0;
let root_color = cat10[i % cat10.len()].to_string();
pending.push(PendingNode {
node,
start_deg: cursor,
sweep_deg: sweep,
inherited_color: Some(root_color),
});
cursor += sweep;
}
let mut next_pending: Vec<PendingNode> = Vec::new();
let mut current_depth = 0usize;
while !pending.is_empty() {
if let Some(md) = sb.max_depth {
if current_depth > md {
break;
}
}
let r_inner = sb.inner_radius_frac * avail_r + current_depth as f64 * ring_w;
let r_outer = (r_inner + ring_w - sb.ring_gap).max(r_inner + 1.0);
for pn in pending.drain(..) {
let node = pn.node;
let is_leaf = node.children.is_empty()
|| sb.max_depth.map(|md| current_depth >= md).unwrap_or(false);
let color_value = if node.children.is_empty() {
let cv = cv_slice.get(leaf_idx).copied();
leaf_idx += 1;
cv
} else {
None
};
let path = node.label.clone();
arcs.push(SbArc {
label: node.label.clone(),
value: node.resolved_value(),
color_value,
inherited_color: pn.inherited_color.clone(),
explicit_color: node.color.clone(),
start_deg: pn.start_deg,
sweep_deg: pn.sweep_deg,
r_inner,
r_outer,
path,
is_leaf,
});
if !node.children.is_empty() && !is_leaf {
let child_total: f64 = node.children.iter().map(|c| c.resolved_value()).sum();
if child_total > f64::EPSILON {
let mut child_cursor = pn.start_deg;
for child in &node.children {
let cv = child.resolved_value();
if cv <= f64::EPSILON {
continue;
}
let child_sweep = cv / child_total * pn.sweep_deg;
next_pending.push(PendingNode {
node: child,
start_deg: child_cursor,
sweep_deg: child_sweep,
inherited_color: pn.inherited_color.clone(),
});
child_cursor += child_sweep;
}
}
}
}
std::mem::swap(&mut pending, &mut next_pending);
current_depth += 1;
}
arcs
}
fn sunburst_leaf_values(sb: &SunburstPlot) -> Vec<f64> {
if let Some(ref cv) = sb.color_values {
return cv.clone();
}
fn collect(nodes: &[TreemapNode], out: &mut Vec<f64>) {
for n in nodes {
if n.children.is_empty() {
out.push(n.resolved_value());
} else {
collect(&n.children, out);
}
}
}
let mut out = Vec::new();
collect(&sb.roots, &mut out);
out
}
fn compute_sunburst_value_range(sb: &SunburstPlot) -> (f64, f64) {
if let Some((lo, hi)) = sb.color_range {
return (lo, hi);
}
let vals = sunburst_leaf_values(sb);
if vals.is_empty() {
return (0.0, 1.0);
}
let lo = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let hi = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(lo, hi)
}
fn add_sunburst(sb: &SunburstPlot, scene: &mut Scene, computed: &ComputedLayout) {
let pw = computed.width - computed.margin_left - computed.margin_right;
let ph = computed.height - computed.margin_top - computed.margin_bottom;
if pw <= 0.0 || ph <= 0.0 {
return;
}
let cx = computed.margin_left + pw / 2.0;
let cy = computed.margin_top + ph / 2.0;
let avail_r = pw.min(ph) / 2.0 - 4.0;
if avail_r <= 0.0 {
return;
}
let arcs = build_sunburst_arcs(sb, avail_r);
if arcs.is_empty() {
return;
}
let (v_min, v_max) = if matches!(sb.color_mode, SunburstColorMode::ByValue(_)) {
compute_sunburst_value_range(sb)
} else {
(0.0, 1.0)
};
let v_span = (v_max - v_min).max(f64::EPSILON);
for arc in &arcs {
let fill_color = match &sb.color_mode {
SunburstColorMode::ByParent => arc
.inherited_color
.as_deref()
.unwrap_or("#888888")
.to_string(),
SunburstColorMode::ByValue(cmap) => {
if arc.is_leaf {
let raw = arc.color_value.unwrap_or(arc.value);
let norm = ((raw - v_min) / v_span).clamp(0.0, 1.0);
cmap.map(norm)
} else {
"#e0e0e0".to_string()
}
}
SunburstColorMode::Explicit => arc
.explicit_color
.as_deref()
.unwrap_or("#888888")
.to_string(),
};
if sb.show_tooltips {
let tip = format!("{}\n{:.4}", arc.path, arc.value);
scene.add(Primitive::GroupStart {
transform: None,
title: Some(tip),
extra_attrs: None,
});
}
let path_d = sunburst_arc_path(
cx,
cy,
arc.r_inner,
arc.r_outer,
arc.start_deg,
arc.sweep_deg,
);
scene.add(Primitive::Path(Box::new(PathData {
d: path_d,
fill: Some(Color::from(fill_color.as_str())),
stroke: Color::from("#ffffff"),
stroke_width: 0.8,
opacity: None,
stroke_dasharray: None,
})));
if sb.show_labels && arc.sweep_deg >= sb.min_label_angle {
let mid_deg = arc.start_deg + arc.sweep_deg / 2.0;
let r_mid = (arc.r_inner + arc.r_outer) / 2.0;
let (lx, ly) = arc_point(cx, cy, r_mid, mid_deg);
let font_size = 11u32;
let char_w_est = font_size as f64 * 0.55;
let arc_len = arc.sweep_deg.to_radians() * r_mid;
let max_chars = ((arc_len * 0.88) / char_w_est).floor().max(0.0) as usize;
let label = if max_chars > 2 && arc.label.chars().count() > max_chars {
let truncated: String = arc
.label
.chars()
.take(max_chars.saturating_sub(1))
.collect();
format!("{}…", truncated)
} else {
arc.label.clone()
};
let rotate = if sb.rotate_labels {
let rotate_deg = if mid_deg > 90.0 && mid_deg < 270.0 {
mid_deg - 180.0
} else {
mid_deg
};
Some(rotate_deg - 90.0)
} else {
None
};
scene.add(Primitive::Text {
x: round2(lx),
y: round2(ly),
content: label,
size: font_size,
anchor: TextAnchor::Middle,
rotate,
bold: false,
color: Some(Color::from("#ffffff")),
});
}
if sb.show_tooltips {
scene.add(Primitive::GroupEnd);
}
}
}
fn add_sunburst_colorbar(sb: &SunburstPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::plot::legend::ColorBarInfo;
use std::sync::Arc;
let cmap = match &sb.color_mode {
SunburstColorMode::ByValue(cmap) => cmap.clone(),
_ => return,
};
if !sb.show_colorbar {
return;
}
let (v_min, v_max) = compute_sunburst_value_range(sb);
let span = (v_max - v_min).max(f64::EPSILON);
let cmin = v_min;
let label = sb
.colorbar_label
.clone()
.unwrap_or_else(|| "Value".to_string());
let cb_info = ColorBarInfo {
map_fn: Arc::new(move |t: f64| cmap.map(((t - cmin) / span).clamp(0.0, 1.0))),
min_value: v_min,
max_value: v_max,
label: Some(label),
tick_labels: None,
};
add_colorbar(&cb_info, scene, computed);
}
pub fn render_sunburst(sb: SunburstPlot, layout: Layout) -> Scene {
let plots = vec![Plot::Sunburst(sb)];
render_multiple(plots, layout)
}
fn nudge_bump_labels(positions: &mut [(usize, f64)], min_gap: f64) {
let n = positions.len();
for _ in 0..20 {
let mut changed = false;
for i in 1..n {
let gap = positions[i].1 - positions[i - 1].1;
if gap < min_gap {
let push = (min_gap - gap) / 2.0;
positions[i - 1].1 -= push;
positions[i].1 += push;
changed = true;
}
}
if !changed {
break;
}
}
}
fn add_bump(bp: &BumpPlot, scene: &mut Scene, computed: &ComputedLayout, layout: &Layout) {
use crate::render::palette::Palette;
let series = bp.resolved_series();
let n = series.len();
let n_time = bp.n_time_points();
if n == 0 || n_time == 0 {
return;
}
let cat10 = Palette::category10();
let highlight = bp.highlight.as_deref();
let _ = layout;
let colors: Vec<String> = series
.iter()
.enumerate()
.map(|(i, s)| s.color.clone().unwrap_or_else(|| cat10[i].to_string()))
.collect();
let mut draw_order: Vec<usize> = (0..n).collect();
if let Some(hl) = highlight {
draw_order.sort_by_key(|&i| if series[i].name == hl { 1 } else { 0 });
}
for &si in &draw_order {
let s = &series[si];
let color = &colors[si];
let is_highlighted = highlight.is_none_or(|hl| s.name == hl);
let opacity = if highlight.is_some() && !is_highlighted {
0.2
} else {
1.0
};
let sw = if is_highlighted && highlight.is_some() {
bp.stroke_width * 1.6
} else {
bp.stroke_width
};
let mut prev: Option<(f64, f64)> = None;
for t in 0..n_time {
let rank_opt = s.ranks.get(t).and_then(|r| *r);
let x_data = (t + 1) as f64;
if let Some(r) = rank_opt {
let y_data = n as f64 + 1.0 - r;
let px = computed.map_x(x_data);
let py = computed.map_y(y_data);
if let Some((ppx, ppy)) = prev {
let path_d = match bp.curve_style {
CurveStyle::Sigmoid => {
let mx = (ppx + px) / 2.0;
format!("M {ppx:.2},{ppy:.2} C {mx:.2},{ppy:.2} {mx:.2},{py:.2} {px:.2},{py:.2}")
}
CurveStyle::Straight => {
format!("M {ppx:.2},{ppy:.2} L {px:.2},{py:.2}")
}
};
scene.add(Primitive::Path(Box::new(PathData {
d: path_d,
fill: None,
stroke: Color::from(color.as_str()),
stroke_width: sw,
opacity: Some(opacity),
stroke_dasharray: None,
})));
}
prev = Some((px, py));
} else {
prev = None; }
}
for t in 0..n_time {
let rank_opt = s.ranks.get(t).and_then(|r| *r);
let x_data = (t + 1) as f64;
if let Some(r) = rank_opt {
let y_data = n as f64 + 1.0 - r;
let px = computed.map_x(x_data);
let py = computed.map_y(y_data);
scene.add(Primitive::Circle {
cx: px,
cy: py,
r: bp.dot_radius,
fill: Color::from(color.as_str()),
fill_opacity: Some(opacity),
stroke: Some(Color::from("#ffffff")),
stroke_width: Some(bp.stroke_width * 0.5),
});
if bp.show_rank_labels {
let label = if (r - r.round()).abs() < f64::EPSILON * 10.0 {
format!("{}", r as i64)
} else {
format!("{:.1}", r)
};
let font_sz = ((bp.dot_radius * 1.1) as u32)
.max(7)
.min(computed.body_size);
scene.add(Primitive::Text {
x: px,
y: py + font_sz as f64 * 0.35,
content: label,
size: font_sz,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: Some(Color::from("#ffffff")),
});
}
}
}
}
if bp.show_series_labels {
let label_gap = bp.dot_radius + 5.0;
let font_h = computed.body_size as f64;
let positions_at = |t: usize| -> Vec<(usize, f64)> {
let mut pos: Vec<(usize, f64)> = series
.iter()
.enumerate()
.filter_map(|(si, s)| {
let r = s.ranks.get(t).and_then(|r| *r)?;
let y_data = n as f64 + 1.0 - r;
let py = computed.map_y(y_data);
Some((si, py))
})
.collect();
pos.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
nudge_bump_labels(&mut pos, font_h * 1.1);
pos
};
let left_px = computed.map_x(1.0);
for (si, py) in positions_at(0) {
let s = &series[si];
let is_highlighted = highlight.is_none_or(|hl| s.name == hl);
let color = if highlight.is_some() && !is_highlighted {
"#bbbbbb".to_string()
} else {
colors[si].clone()
};
scene.add(Primitive::Text {
x: left_px - label_gap,
y: py + font_h * 0.35,
content: s.name.clone(),
size: computed.body_size,
anchor: TextAnchor::End,
rotate: None,
bold: is_highlighted && highlight.is_some(),
color: Some(Color::from(color.as_str())),
});
}
let last_t = n_time.saturating_sub(1);
let right_px = computed.map_x(n_time as f64);
for (si, py) in positions_at(last_t) {
let s = &series[si];
let is_highlighted = highlight.is_none_or(|hl| s.name == hl);
let color = if highlight.is_some() && !is_highlighted {
"#bbbbbb".to_string()
} else {
colors[si].clone()
};
scene.add(Primitive::Text {
x: right_px + label_gap,
y: py + font_h * 0.35,
content: s.name.clone(),
size: computed.body_size,
anchor: TextAnchor::Start,
rotate: None,
bold: is_highlighted && highlight.is_some(),
color: Some(Color::from(color.as_str())),
});
}
}
}
pub fn render_bump(bp: BumpPlot, layout: Layout) -> Scene {
let plots = vec![Plot::Bump(bp)];
render_multiple(plots, layout)
}
fn darken_hex(hex: &str, factor: f64) -> String {
fn parse_comp(s: &str, start: usize) -> u8 {
u8::from_str_radix(&s[start..start + 2], 16).unwrap_or(128)
}
let hex = hex.trim_start_matches('#');
if hex.len() < 6 {
return format!("#{}", hex);
}
let r = (parse_comp(hex, 0) as f64 * factor)
.round()
.clamp(0.0, 255.0) as u8;
let g = (parse_comp(hex, 2) as f64 * factor)
.round()
.clamp(0.0, 255.0) as u8;
let b = (parse_comp(hex, 4) as f64 * factor)
.round()
.clamp(0.0, 255.0) as u8;
format!("#{:02X}{:02X}{:02X}", r, g, b)
}
fn resolve_stage_color(
stage: &FunnelStage,
idx: usize,
n: usize,
color_mode: &FunnelColorMode,
base_color: &str,
) -> String {
if let Some(ref c) = stage.color {
return c.clone();
}
match color_mode {
FunnelColorMode::Uniform => base_color.to_string(),
FunnelColorMode::ByStage => {
use crate::render::palette::Palette;
Palette::category10()[idx % 10].to_string()
}
FunnelColorMode::Gradient => {
let factor = if n <= 1 {
1.0
} else {
1.0 - 0.55 * (idx as f64 / (n - 1) as f64)
};
darken_hex(base_color, factor)
}
}
}
fn add_funnel(fp: &FunnelPlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
if fp.stages.is_empty() {
return;
}
let pw = computed.width - computed.margin_left - computed.margin_right;
let ph = computed.height - computed.margin_top - computed.margin_bottom;
if pw <= 0.0 || ph <= 0.0 {
return;
}
let ox = computed.margin_left;
let oy = computed.margin_top;
let base_color = Palette::category10()[0].to_string();
let is_vertical = matches!(fp.orientation, FunnelOrientation::Vertical);
let is_mirror = fp.mirror.is_some();
let max_val = fp.max_value();
if max_val <= f64::EPSILON {
return;
}
let n = fp.stages.len();
let font_size: u32 = 11;
if is_vertical {
let gap = fp.stage_gap;
let total_gap = gap * (n.saturating_sub(1)) as f64;
let bar_h = ((ph - total_gap) / n as f64).max(4.0);
let max_left_chars = fp.stages.iter().map(|s| s.label.len()).max().unwrap_or(0);
let left_label_w = max_left_chars as f64 * 7.5 + 14.0;
let (max_bar_w, center_x) = if is_mirror {
let max_right_chars = fp
.mirror
.as_ref()
.and_then(|m| m.iter().map(|s| s.label.len()).max())
.unwrap_or(0);
let right_label_w = max_right_chars as f64 * 7.5 + 14.0;
let avail = (pw - left_label_w - right_label_w).max(40.0);
let half = avail / 2.0 - 4.0;
let cx = ox + left_label_w + avail / 2.0;
(half, cx)
} else {
let avail = (pw - left_label_w).max(40.0);
(avail, ox + left_label_w + avail / 2.0)
};
if is_mirror {
if let Some(ref ll) = fp.left_label {
scene.add(Primitive::Text {
x: ox + pw / 4.0,
y: oy - 6.0,
content: ll.clone(),
size: font_size + 1,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
}
if let Some(ref rl) = fp.right_label {
scene.add(Primitive::Text {
x: ox + 3.0 * pw / 4.0,
y: oy - 6.0,
content: rl.clone(),
size: font_size + 1,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
}
}
if is_mirror {
scene.add(Primitive::Line {
x1: center_x,
y1: oy,
x2: center_x,
y2: oy + ph,
stroke: Color::from("#cccccc"),
stroke_width: 1.0,
stroke_dasharray: Some("4,3".to_string()),
});
}
for (i, stage) in fp.stages.iter().enumerate() {
let bar_y = oy + i as f64 * (bar_h + gap);
let frac = stage.value / max_val;
let half_w = frac * max_bar_w / 2.0;
let color = resolve_stage_color(stage, i, n, &fp.color_mode, &base_color);
let (bar_x, bar_w) = if is_mirror {
(center_x - half_w, half_w)
} else {
(center_x - frac * max_bar_w / 2.0, frac * max_bar_w)
};
scene.add(Primitive::Rect {
x: bar_x,
y: bar_y,
width: bar_w,
height: bar_h,
fill: Color::from(color.as_str()),
stroke: None,
stroke_width: None,
opacity: None,
});
if fp.show_connectors && i + 1 < n {
let next_frac = fp.stages[i + 1].value / max_val;
let next_half_w = next_frac * max_bar_w / 2.0;
let cy0 = bar_y + bar_h;
let cy1 = bar_y + bar_h + gap;
let (lx0, rx0, lx1, rx1) = if is_mirror {
(
center_x - half_w,
center_x,
center_x - next_half_w,
center_x,
)
} else {
(
center_x - half_w,
center_x + half_w,
center_x - next_half_w,
center_x + next_half_w,
)
};
let d = format!(
"M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
lx0, cy0, rx0, cy0, rx1, cy1, lx1, cy1
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(Color::from(color.as_str())),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(fp.connector_opacity),
stroke_dasharray: None,
})));
if fp.show_conversion && gap >= 10.0 {
let rate = if stage.value > f64::EPSILON {
fp.stages[i + 1].value / stage.value * 100.0
} else {
0.0
};
let mid_y = cy0 + gap / 2.0;
let mid_x = if is_mirror {
center_x - (half_w + next_half_w) / 4.0
} else {
center_x
};
scene.add(Primitive::Text {
x: mid_x,
y: mid_y + 4.0,
content: format!("{:.1}%", rate),
size: font_size - 1,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: Some(Color::from("#555555")),
});
}
}
if fp.show_values {
let label = if fp.show_percents {
let pct = stage.value / fp.stages[0].value * 100.0;
format!("{:.0} ({:.1}%)", stage.value, pct)
} else {
format!("{:.0}", stage.value)
};
let text_fits = bar_w > 60.0 && bar_h > (font_size as f64 + 2.0);
let (lx, anchor, color_text) = if text_fits {
(
bar_x + bar_w / 2.0,
TextAnchor::Middle,
Color::from("#ffffff"),
)
} else {
let rx = if is_mirror {
bar_x
} else {
bar_x + bar_w + 4.0
};
let anc = if is_mirror {
TextAnchor::End
} else {
TextAnchor::Start
};
(rx, anc, Color::from("#333333"))
};
scene.add(Primitive::Text {
x: lx,
y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
content: label,
size: font_size,
anchor,
rotate: None,
bold: false,
color: Some(color_text),
});
}
{
let (lx, anchor) = if is_mirror {
(center_x - max_bar_w / 2.0 - 6.0, TextAnchor::End)
} else {
(bar_x - 6.0, TextAnchor::End)
};
scene.add(Primitive::Text {
x: lx,
y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
content: stage.label.clone(),
size: font_size,
anchor,
rotate: None,
bold: false,
color: None,
});
}
if let Some(ref mirror_stages) = fp.mirror {
if let Some(ms) = mirror_stages.get(i) {
let m_frac = ms.value / max_val;
let m_half_w = m_frac * max_bar_w / 2.0;
let m_color = resolve_stage_color(
ms,
i,
mirror_stages.len(),
&fp.color_mode,
&base_color,
);
scene.add(Primitive::Rect {
x: center_x,
y: bar_y,
width: m_half_w,
height: bar_h,
fill: Color::from(m_color.as_str()),
stroke: None,
stroke_width: None,
opacity: None,
});
if fp.show_connectors && i + 1 < mirror_stages.len() {
let next_m_frac = mirror_stages[i + 1].value / max_val;
let next_m_half_w = next_m_frac * max_bar_w / 2.0;
let cy0 = bar_y + bar_h;
let cy1 = bar_y + bar_h + gap;
let d = format!(
"M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
center_x,
cy0,
center_x + m_half_w,
cy0,
center_x + next_m_half_w,
cy1,
center_x,
cy1,
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(Color::from(m_color.as_str())),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(fp.connector_opacity),
stroke_dasharray: None,
})));
if fp.show_conversion && gap >= 10.0 {
let rate = if ms.value > f64::EPSILON {
mirror_stages[i + 1].value / ms.value * 100.0
} else {
0.0
};
let mid_y = cy0 + gap / 2.0;
let mid_x = center_x + (m_half_w + next_m_half_w) / 4.0;
scene.add(Primitive::Text {
x: mid_x,
y: mid_y + 4.0,
content: format!("{:.1}%", rate),
size: font_size - 1,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: Some(Color::from("#555555")),
});
}
}
if fp.show_values {
let label = if fp.show_percents {
let pct = if mirror_stages[0].value > f64::EPSILON {
ms.value / mirror_stages[0].value * 100.0
} else {
0.0
};
format!("{:.0} ({:.1}%)", ms.value, pct)
} else {
format!("{:.0}", ms.value)
};
let m_bar_w = m_half_w;
let text_fits = m_bar_w > 60.0 && bar_h > (font_size as f64 + 2.0);
let (lx, anchor, color_text) = if text_fits {
(
center_x + m_bar_w / 2.0,
TextAnchor::Middle,
Color::from("#ffffff"),
)
} else {
(
center_x + m_bar_w + 4.0,
TextAnchor::Start,
Color::from("#333333"),
)
};
scene.add(Primitive::Text {
x: lx,
y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
content: label,
size: font_size,
anchor,
rotate: None,
bold: false,
color: Some(color_text),
});
}
scene.add(Primitive::Text {
x: center_x + max_bar_w / 2.0 + 6.0,
y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
content: ms.label.clone(),
size: font_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
}
} else {
let gap = fp.stage_gap;
let total_gap = gap * (n.saturating_sub(1)) as f64;
let bar_w = ((pw - total_gap) / n as f64).max(4.0);
let max_bar_h = if is_mirror { ph / 2.0 - 4.0 } else { ph };
let center_y = oy + ph / 2.0;
if is_mirror {
if let Some(ref ll) = fp.left_label {
scene.add(Primitive::Text {
x: ox - 8.0,
y: oy + ph / 4.0,
content: ll.clone(),
size: font_size + 1,
anchor: TextAnchor::End,
rotate: Some(-90.0),
bold: true,
color: None,
});
}
if let Some(ref rl) = fp.right_label {
scene.add(Primitive::Text {
x: ox - 8.0,
y: oy + 3.0 * ph / 4.0,
content: rl.clone(),
size: font_size + 1,
anchor: TextAnchor::End,
rotate: Some(-90.0),
bold: true,
color: None,
});
}
}
if is_mirror {
scene.add(Primitive::Line {
x1: ox,
y1: center_y,
x2: ox + pw,
y2: center_y,
stroke: Color::from("#cccccc"),
stroke_width: 1.0,
stroke_dasharray: Some("4,3".to_string()),
});
}
for (i, stage) in fp.stages.iter().enumerate() {
let bar_x = ox + i as f64 * (bar_w + gap);
let frac = stage.value / max_val;
let half_h = frac * max_bar_h / 2.0;
let color = resolve_stage_color(stage, i, n, &fp.color_mode, &base_color);
let (bar_y, actual_bar_h) = if is_mirror {
(center_y - half_h, half_h)
} else {
(center_y - frac * max_bar_h / 2.0, frac * max_bar_h)
};
scene.add(Primitive::Rect {
x: bar_x,
y: bar_y,
width: bar_w,
height: actual_bar_h,
fill: Color::from(color.as_str()),
stroke: None,
stroke_width: None,
opacity: None,
});
if fp.show_connectors && i + 1 < n {
let next_frac = fp.stages[i + 1].value / max_val;
let next_half_h = next_frac * max_bar_h / 2.0;
let cx0 = bar_x + bar_w;
let cx1 = bar_x + bar_w + gap;
let (ty0, by0, ty1, by1) = if is_mirror {
(
center_y - half_h,
center_y,
center_y - next_half_h,
center_y,
)
} else {
(
center_y - half_h,
center_y + half_h,
center_y - next_half_h,
center_y + next_half_h,
)
};
let d = format!(
"M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
cx0, ty0, cx0, by0, cx1, by1, cx1, ty1
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(Color::from(color.as_str())),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(fp.connector_opacity),
stroke_dasharray: None,
})));
if fp.show_conversion && gap >= 10.0 {
let rate = if stage.value > f64::EPSILON {
fp.stages[i + 1].value / stage.value * 100.0
} else {
0.0
};
let mid_x = cx0 + gap / 2.0;
let mid_y = if is_mirror {
center_y - (half_h + next_half_h) / 4.0
} else {
center_y
};
scene.add(Primitive::Text {
x: mid_x,
y: mid_y + 4.0,
content: format!("{:.1}%", rate),
size: font_size - 1,
anchor: TextAnchor::Middle,
rotate: Some(-90.0),
bold: false,
color: Some(Color::from("#555555")),
});
}
}
if fp.show_values {
let label = if fp.show_percents {
let pct = stage.value / fp.stages[0].value * 100.0;
format!("{:.0} ({:.1}%)", stage.value, pct)
} else {
format!("{:.0}", stage.value)
};
let text_fits = actual_bar_h > 20.0 && bar_w > 30.0;
let (lx, ly, anchor, color_text) = if text_fits {
(
bar_x + bar_w / 2.0,
bar_y + actual_bar_h / 2.0 + font_size as f64 * 0.35,
TextAnchor::Middle,
Color::from("#ffffff"),
)
} else {
(
bar_x + bar_w / 2.0,
bar_y - 4.0,
TextAnchor::Middle,
Color::from("#333333"),
)
};
scene.add(Primitive::Text {
x: lx,
y: ly,
content: label,
size: font_size,
anchor,
rotate: None,
bold: false,
color: Some(color_text),
});
}
scene.add(Primitive::Text {
x: bar_x + bar_w / 2.0,
y: oy + ph + 14.0,
content: stage.label.clone(),
size: font_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
if let Some(ref mirror_stages) = fp.mirror {
if let Some(ms) = mirror_stages.get(i) {
let m_frac = ms.value / max_val;
let m_half_h = m_frac * max_bar_h / 2.0;
let m_color = resolve_stage_color(
ms,
i,
mirror_stages.len(),
&fp.color_mode,
&base_color,
);
scene.add(Primitive::Rect {
x: bar_x,
y: center_y,
width: bar_w,
height: m_half_h,
fill: Color::from(m_color.as_str()),
stroke: None,
stroke_width: None,
opacity: None,
});
if fp.show_connectors && i + 1 < mirror_stages.len() {
let next_m_frac = mirror_stages[i + 1].value / max_val;
let next_m_half_h = next_m_frac * max_bar_h / 2.0;
let cx0 = bar_x + bar_w;
let cx1 = bar_x + bar_w + gap;
let d = format!(
"M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
cx0,
center_y,
cx0,
center_y + m_half_h,
cx1,
center_y + next_m_half_h,
cx1,
center_y,
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(Color::from(m_color.as_str())),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(fp.connector_opacity),
stroke_dasharray: None,
})));
}
if fp.show_values {
let label = if fp.show_percents {
let pct = if mirror_stages[0].value > f64::EPSILON {
ms.value / mirror_stages[0].value * 100.0
} else {
0.0
};
format!("{:.0} ({:.1}%)", ms.value, pct)
} else {
format!("{:.0}", ms.value)
};
let text_fits = m_half_h > 20.0 && bar_w > 30.0;
let (lx, ly, anchor, color_text) = if text_fits {
(
bar_x + bar_w / 2.0,
center_y + m_half_h / 2.0 + font_size as f64 * 0.35,
TextAnchor::Middle,
Color::from("#ffffff"),
)
} else {
(
bar_x + bar_w / 2.0,
center_y + m_half_h + 14.0,
TextAnchor::Middle,
Color::from("#333333"),
)
};
scene.add(Primitive::Text {
x: lx,
y: ly,
content: label,
size: font_size,
anchor,
rotate: None,
bold: false,
color: Some(color_text),
});
}
}
}
}
}
}
pub fn render_funnel(fp: FunnelPlot, layout: Layout) -> Scene {
let plots = vec![Plot::Funnel(fp)];
render_multiple(plots, layout)
}
fn compass_xy(cx: f64, cy: f64, r: f64, deg: f64) -> (f64, f64) {
let rad = deg.to_radians();
(cx + r * rad.sin(), cy - r * rad.cos())
}
fn rose_r(cum: f64, max_val: f64, max_r: f64, base_r: f64, enc: &RoseEncoding) -> f64 {
if max_val <= f64::EPSILON || cum <= 0.0 {
return base_r;
}
let frac = (cum / max_val).clamp(0.0, 1.0);
match enc {
RoseEncoding::Area => (base_r * base_r + frac * (max_r * max_r - base_r * base_r)).sqrt(),
RoseEncoding::Radius => base_r + frac * (max_r - base_r),
}
}
fn rose_wedge(cx: f64, cy: f64, r_inner: f64, r_outer: f64, a1: f64, a2: f64, cw: bool) -> String {
let (ox1, oy1) = compass_xy(cx, cy, r_outer, a1);
let (ox2, oy2) = compass_xy(cx, cy, r_outer, a2);
let mut span = if cw { a2 - a1 } else { a1 - a2 };
while span < 0.0 {
span += 360.0;
}
while span >= 360.0 {
span -= 360.0;
}
let la = if span > 180.0 { 1 } else { 0 };
let s_out = if cw { 1 } else { 0 };
let s_in = 1 - s_out;
if r_inner < 0.5 {
format!(
"M {cx:.2},{cy:.2} L {ox1:.2},{oy1:.2} \
A {r_outer:.2},{r_outer:.2} 0 {la},{s_out} {ox2:.2},{oy2:.2} Z"
)
} else {
let (ix1, iy1) = compass_xy(cx, cy, r_inner, a1);
let (ix2, iy2) = compass_xy(cx, cy, r_inner, a2);
format!(
"M {ox1:.2},{oy1:.2} \
A {r_outer:.2},{r_outer:.2} 0 {la},{s_out} {ox2:.2},{oy2:.2} \
L {ix2:.2},{iy2:.2} \
A {r_inner:.2},{r_inner:.2} 0 {la},{s_in} {ix1:.2},{iy1:.2} Z"
)
}
}
fn rose_sector_angles(idx: usize, n: usize, start: f64, cw: bool, gap: f64) -> (f64, f64) {
let sd = 360.0 / n as f64;
let d = if cw { 1.0 } else { -1.0 };
(
start + d * (idx as f64 * sd + gap / 2.0),
start + d * ((idx + 1) as f64 * sd - gap / 2.0),
)
}
fn rose_center_angle(idx: usize, n: usize, start: f64, cw: bool) -> f64 {
let sd = 360.0 / n as f64;
let d = if cw { 1.0 } else { -1.0 };
start + d * (idx as f64 + 0.5) * sd
}
fn rose_sub_angles(
si: usize,
ji: usize,
n: usize,
ns: usize,
start: f64,
cw: bool,
gap: f64,
) -> (f64, f64) {
let sd = 360.0 / n as f64;
let d = if cw { 1.0 } else { -1.0 };
let usable = sd - gap;
let sub_d = usable / ns as f64;
let sub_gap = (sub_d * 0.08).clamp(0.3_f64, 1.5_f64);
let sector_start = start + d * (si as f64 * sd + gap / 2.0);
(
sector_start + d * (ji as f64 * sub_d + sub_gap / 2.0),
sector_start + d * ((ji + 1) as f64 * sub_d - sub_gap / 2.0),
)
}
fn rose_fmt(v: f64) -> String {
if v <= 0.0 {
return "0".to_string();
}
if v >= 1_000_000.0 {
return format!("{:.1}M", v / 1_000_000.0);
}
if v >= 1_000.0 {
return format!("{:.1}k", v / 1_000.0);
}
if v == v.floor() {
return format!("{:.0}", v);
}
if v < 10.0 {
return format!("{:.1}", v);
}
format!("{:.0}", v)
}
fn add_rose(rp: &RosePlot, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
let pw = computed.width - computed.margin_left - computed.margin_right;
let ph = computed.height - computed.margin_top - computed.margin_bottom;
if pw <= 0.0 || ph <= 0.0 {
return;
}
let cx = computed.margin_left + pw / 2.0;
let cy = computed.margin_top + ph / 2.0;
let n = rp.n_sectors();
if n == 0 {
return;
}
let label_margin = if rp.show_labels { 34.0 } else { 8.0 };
let max_r = (pw.min(ph) / 2.0 - label_margin).max(10.0);
let base_r = rp.inner_radius * max_r;
let max_total = rp.max_total();
let cat10 = Palette::category10();
if rp.show_grid && rp.grid_lines > 0 {
for k in 1..=rp.grid_lines {
let gr = max_r * k as f64 / rp.grid_lines as f64;
let d = format!(
"M {:.2},{:.2} A {gr:.2},{gr:.2} 0 1,0 {:.2},{:.2} \
A {gr:.2},{gr:.2} 0 1,0 {:.2},{:.2} Z",
cx - gr,
cy,
cx + gr,
cy,
cx - gr,
cy
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: Color::from("#cccccc"),
stroke_width: 0.7,
opacity: None,
stroke_dasharray: Some("3,3".to_string()),
})));
if max_total > f64::EPSILON {
let ring_val = match rp.encoding {
RoseEncoding::Area => {
let denom = max_r * max_r - base_r * base_r;
if denom > f64::EPSILON {
(gr * gr - base_r * base_r).max(0.0) / denom * max_total
} else {
0.0
}
}
RoseEncoding::Radius => {
let denom = max_r - base_r;
if denom > f64::EPSILON {
(gr - base_r).max(0.0) / denom * max_total
} else {
0.0
}
}
};
let (lx, ly) = compass_xy(cx, cy, gr, rp.start_angle);
scene.add(Primitive::Text {
x: lx + 3.0,
y: ly - 2.0,
content: rose_fmt(ring_val),
size: 8,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(Color::from("#aaaaaa")),
});
}
}
}
if rp.show_spokes {
for i in 0..n {
let a = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
let (sx, sy) = compass_xy(cx, cy, max_r, a);
let inner_r = if base_r > 0.5 { base_r } else { 0.0 };
let (bx, by) = compass_xy(cx, cy, inner_r, a);
scene.add(Primitive::Line {
x1: bx,
y1: by,
x2: sx,
y2: sy,
stroke: Color::from("#dddddd"),
stroke_width: 0.5,
stroke_dasharray: None,
});
}
}
if max_total > f64::EPSILON {
match &rp.mode {
RoseMode::Stacked => {
for i in 0..n {
let (a1, a2) = rose_sector_angles(i, n, rp.start_angle, rp.clockwise, rp.gap);
let mut cum = 0.0_f64;
let mut last_r_inn = base_r;
for (j, series) in rp.series.iter().enumerate() {
let val = series.values.get(i).copied().unwrap_or(0.0).max(0.0);
let r_inn = rose_r(cum, max_total, max_r, base_r, &rp.encoding);
cum += val;
let r_out = rose_r(cum, max_total, max_r, base_r, &rp.encoding);
if r_out <= r_inn + 0.5 {
continue;
}
last_r_inn = r_inn;
let color = series
.color
.clone()
.unwrap_or_else(|| cat10[j % 10].to_string());
let d = rose_wedge(cx, cy, r_inn, r_out, a1, a2, rp.clockwise);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(Color::from(color.as_str())),
stroke: Color::from("#ffffff"),
stroke_width: 0.5,
opacity: Some(0.75),
stroke_dasharray: None,
})));
}
if rp.show_values && cum > f64::EPSILON {
let r_out_tip = rose_r(cum, max_total, max_r, base_r, &rp.encoding);
let ac = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
let (lx, ly) = if rp.show_labels && r_out_tip - last_r_inn > 16.0 {
compass_xy(cx, cy, r_out_tip - 8.0, ac)
} else {
compass_xy(cx, cy, r_out_tip + 8.0, ac)
};
scene.add(Primitive::Text {
x: lx,
y: ly + 4.0,
content: rose_fmt(cum),
size: 9,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
RoseMode::Grouped => {
let ns = rp.series.len();
if ns == 0 {
return;
}
for i in 0..n {
for (j, series) in rp.series.iter().enumerate() {
let val = series.values.get(i).copied().unwrap_or(0.0).max(0.0);
let r_out = rose_r(val, max_total, max_r, base_r, &rp.encoding);
if r_out <= base_r + 0.5 {
continue;
}
let (a1, a2) =
rose_sub_angles(i, j, n, ns, rp.start_angle, rp.clockwise, rp.gap);
let color = series
.color
.clone()
.unwrap_or_else(|| cat10[j % 10].to_string());
let d = rose_wedge(cx, cy, base_r, r_out, a1, a2, rp.clockwise);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(Color::from(color.as_str())),
stroke: Color::from("#ffffff"),
stroke_width: 0.5,
opacity: Some(0.75),
stroke_dasharray: None,
})));
}
if rp.show_values {
let max_val = rp
.series
.iter()
.map(|s| s.values.get(i).copied().unwrap_or(0.0))
.fold(0.0_f64, f64::max);
if max_val > f64::EPSILON {
let r_out_tip = rose_r(max_val, max_total, max_r, base_r, &rp.encoding);
let ac = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
let (lx, ly) = if rp.show_labels && r_out_tip - base_r > 16.0 {
compass_xy(cx, cy, r_out_tip - 8.0, ac)
} else {
compass_xy(cx, cy, r_out_tip + 8.0, ac)
};
scene.add(Primitive::Text {
x: lx,
y: ly + 4.0,
content: rose_fmt(max_val),
size: 9,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
}
}
}
{
let d = format!(
"M {:.2},{:.2} A {max_r:.2},{max_r:.2} 0 1,0 {:.2},{:.2} \
A {max_r:.2},{max_r:.2} 0 1,0 {:.2},{:.2} Z",
cx - max_r,
cy,
cx + max_r,
cy,
cx - max_r,
cy
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: Color::from("#aaaaaa"),
stroke_width: 0.8,
opacity: None,
stroke_dasharray: None,
})));
}
if base_r > 0.5 {
let d = format!(
"M {:.2},{:.2} A {base_r:.2},{base_r:.2} 0 1,0 {:.2},{:.2} \
A {base_r:.2},{base_r:.2} 0 1,0 {:.2},{:.2} Z",
cx - base_r,
cy,
cx + base_r,
cy,
cx - base_r,
cy
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: Color::from("#aaaaaa"),
stroke_width: 0.8,
opacity: None,
stroke_dasharray: None,
})));
}
if rp.show_labels {
for i in 0..n {
let label = rp
.labels
.get(i)
.cloned()
.unwrap_or_else(|| (i + 1).to_string());
let ac = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
let (lx, ly) = compass_xy(cx, cy, max_r + 16.0, ac);
scene.add(Primitive::Text {
x: lx,
y: ly + 4.0,
content: label,
size: 11,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
}
pub fn render_rose(rp: RosePlot, layout: Layout) -> Scene {
let plots = vec![Plot::Rose(rp)];
render_multiple(plots, layout)
}
const CALENDAR_TIP_JS: &str = r#"(function(){
var svg=document.currentScript?document.currentScript.closest('svg'):document.querySelector('svg');
if(!svg)return;
var tip=document.createElementNS('http://www.w3.org/2000/svg','g');
tip.setAttribute('id','cal-tip');
tip.setAttribute('pointer-events','none');
tip.setAttribute('style','display:none');
var bg=document.createElementNS('http://www.w3.org/2000/svg','rect');
bg.setAttribute('rx','4');
bg.setAttribute('fill','#1a1a1a');
bg.setAttribute('fill-opacity','0.88');
tip.appendChild(bg);
var lines=[];
for(var i=0;i<3;i++){
var t=document.createElementNS('http://www.w3.org/2000/svg','text');
t.setAttribute('fill','white');
t.setAttribute('font-size','11');
t.setAttribute('font-family','sans-serif');
tip.appendChild(t);
lines.push(t);
}
svg.appendChild(tip);
function showTip(e,el){
var date=el.getAttribute('data-date')||'';
var val=el.getAttribute('data-val')||'';
var agg=el.getAttribute('data-agg')||'';
var svgRect=svg.getBoundingClientRect();
var vbw=svg.viewBox&&svg.viewBox.baseVal.width?svg.viewBox.baseVal.width:svgRect.width;
var vbh=svg.viewBox&&svg.viewBox.baseVal.height?svg.viewBox.baseVal.height:svgRect.height;
var sx=vbw/svgRect.width;
var sy=vbh/svgRect.height;
var px=(e.clientX-svgRect.left)*sx+12;
var py=(e.clientY-svgRect.top)*sy-8;
var ls=[date,val];
if(agg)ls.push(agg);
var maxLen=0;
ls.forEach(function(l){if(l.length>maxLen)maxLen=l.length;});
var lh=16,pad=8;
var w=Math.max(maxLen*6.3+pad*2,80);
var h=ls.length*lh+pad*2;
if(px+w>vbw-5)px=px-w-24;
if(py-h<5)py=py+h+10;
bg.setAttribute('x',px);bg.setAttribute('y',py-h);
bg.setAttribute('width',w);bg.setAttribute('height',h);
ls.forEach(function(line,i){
var t=lines[i];
t.setAttribute('x',px+pad);
t.setAttribute('y',py-h+pad+lh*(i+0.75));
t.textContent=line;
t.setAttribute('font-weight',i===0?'600':'normal');
t.setAttribute('style','');
});
for(var i=ls.length;i<lines.length;i++){lines[i].textContent='';}
tip.setAttribute('style','display:block');
}
function hideTip(){tip.setAttribute('style','display:none');}
var days=svg.querySelectorAll('.cal-day');
days.forEach(function(el){
el.addEventListener('mouseover',function(e){showTip(e,el);});
el.addEventListener('mousemove',function(e){showTip(e,el);});
el.addEventListener('mouseout',hideTip);
});
})();"#;
fn add_calendar(cp: &CalendarPlot, scene: &mut Scene, computed: &ComputedLayout) {
let agg_data = cp.aggregate();
let periods = cp.detect_periods();
if periods.is_empty() {
return;
}
let sunday_start = matches!(cp.week_start, WeekStart::Sunday);
let max_label_len = periods
.iter()
.map(|(l, _, _)| l.chars().count())
.max()
.unwrap_or(4);
let day_label_w: f64 = if cp.show_day_labels {
(max_label_len as f64 * 7.5).ceil().max(28.0)
} else {
(max_label_len as f64 * 7.5).ceil().max(32.0)
};
let month_label_h: f64 = if cp.show_month_labels { 16.0 } else { 0.0 };
let period_label_h: f64 = 16.0;
let period_gap = 14.0;
let np = periods.len() as f64;
let legend_h = if cp.show_legend { 50.0 } else { 0.0 };
let max_cols: u32 = periods
.iter()
.map(|(_, start, end)| {
let sdow = if sunday_start {
(dow_mon0(start.0, start.1, start.2) + 1) % 7 } else {
dow_mon0(start.0, start.1, start.2)
};
period_max_cols(*start, *end, sdow)
})
.max()
.unwrap_or(53)
.max(1);
let margin = 16.0; let avail_w = (computed.width - day_label_w - margin).max(1.0);
let avail_h_per_period = ((computed.height
- np * (period_label_h + month_label_h)
- (np - 1.0) * period_gap
- legend_h
- margin)
/ np)
.max(1.0);
let max_pitch_w = avail_w / max_cols as f64;
let max_pitch_h = avail_h_per_period / 7.0;
let effective_cell_size = cp
.cell_size
.min((max_pitch_w.min(max_pitch_h) - cp.cell_gap).max(1.0));
let pitch = effective_cell_size + cp.cell_gap;
let grid_h = 7.0 * pitch;
let (v_min, v_max) = if let Some(r) = cp.value_range {
r
} else {
let mut mx = f64::NEG_INFINITY;
for &v in agg_data.values() {
mx = mx.max(v);
}
if !mx.is_finite() {
mx = 1.0;
}
(0.0, mx)
};
let v_range = (v_max - v_min).max(f64::EPSILON);
let grid_w = max_cols as f64 * pitch;
let total_content_w = day_label_w + grid_w;
let total_content_h =
np * (period_label_h + month_label_h + grid_h) + (np - 1.0) * period_gap + legend_h;
let ox = ((computed.width - total_content_w) / 2.0).max(8.0);
let oy = ((computed.height - total_content_h) / 2.0).max(8.0);
let grid_x = ox + day_label_w;
let month_abbr = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let dow_labels_mon = ["Mon", "", "Wed", "", "Fri", "", "Sun"];
let dow_labels_sun = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
let dow_labels: &[&str] = if sunday_start {
&dow_labels_sun
} else {
&dow_labels_mon
};
let text_color = computed.theme.text_color.as_str();
let sep_color = "#c0c0c0";
for (pi, (label, period_start, period_end)) in periods.iter().enumerate() {
let period_top = oy + pi as f64 * (period_label_h + month_label_h + grid_h + period_gap);
let grid_y = period_top + period_label_h + month_label_h;
let start_dow: u32 = if sunday_start {
(dow_mon0(period_start.0, period_start.1, period_start.2) + 1) % 7
} else {
dow_mon0(period_start.0, period_start.1, period_start.2)
};
scene.add(Primitive::Text {
x: grid_x - 4.0,
y: period_top + period_label_h * 0.78,
content: label.clone(),
size: 11,
anchor: TextAnchor::End,
rotate: None,
bold: true,
color: None,
});
if cp.show_day_labels {
for (ri, &lbl) in dow_labels.iter().enumerate() {
if lbl.is_empty() {
continue;
}
scene.add(Primitive::Text {
x: grid_x - 4.0,
y: grid_y + ri as f64 * pitch + pitch * 0.75,
content: lbl.to_string(),
size: 9,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: Some(Color::from(text_color)),
});
}
}
let mut month_entries: Vec<(String, u32, u32)> = Vec::new();
let mut seen_months: std::collections::HashSet<String> = std::collections::HashSet::new();
let start_jd = to_jd(period_start.0, period_start.1, period_start.2);
let end_jd = to_jd(period_end.0, period_end.1, period_end.2);
for jd in start_jd..=end_jd {
let (y, m, d) = from_jd(jd);
let date_triple = (y, m, d);
let (col, row) = period_grid_pos(date_triple, *period_start, start_dow);
if col >= max_cols {
continue;
}
let month_key = format!("{y}-{m:02}");
if seen_months.insert(month_key) {
month_entries.push((month_abbr[(m - 1) as usize].to_string(), col, row));
}
let date_str = format!("{y}-{m:02}-{d:02}");
let px = grid_x + col as f64 * pitch;
let py = grid_y + row as f64 * pitch;
let (fill_color, tip_val) = if let Some(&v) = agg_data.get(&date_str) {
let fill = if v == 0.0 {
cp.zero_color
.as_deref()
.unwrap_or(&cp.missing_color)
.to_string()
} else {
let norm = ((v - v_min) / v_range).clamp(0.0, 1.0);
cp.color_map.map(norm)
};
(fill, format_val(v, &cp.aggregation))
} else {
(cp.missing_color.clone(), "no data".to_string())
};
let extra = format!(r#"class="cal-day" data-date="{date_str}" data-val="{tip_val}""#);
scene.add(Primitive::GroupStart {
transform: None,
title: None,
extra_attrs: Some(extra),
});
scene.add(Primitive::Rect {
x: px,
y: py,
width: cp.cell_size,
height: cp.cell_size,
fill: Color::from(fill_color),
stroke: None,
stroke_width: None,
opacity: None,
});
scene.add(Primitive::GroupEnd);
}
if cp.show_month_labels {
for (abbr, col, _) in &month_entries {
scene.add(Primitive::Text {
x: grid_x + *col as f64 * pitch,
y: grid_y - 4.0,
content: abbr.clone(),
size: 9,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(Color::from(text_color)),
});
}
}
for (_, col, row) in month_entries.iter().skip(1) {
let col = *col;
let row = *row;
let sep_near = grid_x + col as f64 * pitch;
let sep_far = sep_near + pitch;
let top_y = grid_y;
let mid_y = grid_y + row as f64 * pitch;
let bot_y = grid_y + 7.0 * pitch;
let d = if row == 0 {
format!("M {sep_near} {top_y} L {sep_near} {bot_y}")
} else {
format!("M {sep_far} {top_y} L {sep_far} {mid_y} L {sep_near} {mid_y} L {sep_near} {bot_y}")
};
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: None,
stroke: Color::from(sep_color),
stroke_width: 1.0,
opacity: None,
stroke_dasharray: None,
})));
}
}
if cp.show_legend {
let legend_y =
oy + np * (period_label_h + month_label_h + grid_h) + (np - 1.0) * period_gap + 10.0;
let bar_w = grid_w.min(160.0);
let bar_h = 10.0;
let bar_x = grid_x + (grid_w - bar_w) / 2.0;
let n_stops = 40usize;
let rw = bar_w / n_stops as f64;
for i in 0..n_stops {
let t = i as f64 / (n_stops - 1) as f64;
let color = cp.color_map.map(t);
let rx = bar_x + (i as f64 * rw).floor();
scene.add(Primitive::Rect {
x: rx,
y: legend_y,
width: rw.ceil(),
height: bar_h,
fill: Color::from(color),
stroke: None,
stroke_width: None,
opacity: None,
});
}
scene.add(Primitive::Text {
x: bar_x,
y: legend_y + bar_h + 11.0,
content: format_val_short(v_min),
size: 9,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
scene.add(Primitive::Text {
x: bar_x + bar_w,
y: legend_y + bar_h + 11.0,
content: format_val_short(v_max),
size: 9,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: None,
});
if let Some(ref lbl) = cp.legend_label {
scene.add(Primitive::Text {
x: bar_x + bar_w / 2.0,
y: legend_y + bar_h + 24.0,
content: lbl.clone(),
size: 10,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: None,
});
}
}
if !scene.scripts.iter().any(|s| s.contains("cal-tip")) {
scene.scripts.push(CALENDAR_TIP_JS.to_string());
}
}
fn format_val(v: f64, agg: &CalendarAgg) -> String {
match agg {
CalendarAgg::Count => format!("{} event{}", v as u64, if v as u64 == 1 { "" } else { "s" }),
_ => format_val_short(v),
}
}
fn format_val_short(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e9 {
format!("{}", v as i64)
} else if v.abs() < 0.01 || v.abs() >= 1e5 {
format!("{:.2e}", v)
} else {
format!("{:.2}", v)
}
}
pub fn render_calendar(cp: CalendarPlot, layout: Layout) -> Scene {
let periods = cp.detect_periods();
let (nat_w, nat_h) = cp.natural_size_for_periods(&periods);
let layout = if layout.width.is_none() && layout.height.is_none() {
layout.with_width(nat_w).with_height(nat_h)
} else {
layout
};
let plots = vec![Plot::Calendar(cp)];
render_multiple(plots, layout)
}
fn add_pyramid(pp: &PopulationPyramid, scene: &mut Scene, computed: &ComputedLayout) {
use crate::render::palette::Palette;
if pp.series.is_empty() {
return;
}
let n_groups = pp.n_groups();
if n_groups == 0 {
return;
}
let n_series = pp.series.len();
let cat10 = Palette::category10();
let denom = if pp.normalize {
pp.total_population().max(1e-10) / 100.0
} else {
1.0
};
let is_grouped = matches!(pp.mode, PyramidMode::Grouped);
let group_gap = pp.group_gap;
let bar_gap = pp.bar_gap;
let slot_frac = 1.0 - group_gap;
let sub_h = if is_grouped && n_series > 1 {
(slot_frac - (n_series as f64 - 1.0) * bar_gap) / n_series as f64
} else {
slot_frac
};
let center_x = computed.map_x(0.0);
for (j, series) in pp.series.iter().enumerate() {
let series_color = series
.color
.clone()
.unwrap_or_else(|| cat10[j % cat10.len()].to_string());
let opacity = match pp.mode {
PyramidMode::Overlap => Some(series.opacity),
PyramidMode::Grouped => None,
};
for (i, (_, left_raw, right_raw)) in series.groups.iter().enumerate() {
let left_val = left_raw / denom;
let right_val = right_raw / denom;
let y_center = i as f64 + 1.0;
let (y_bot, y_top) = if is_grouped && n_series > 1 {
let bot = y_center - slot_frac / 2.0 + j as f64 * (sub_h + bar_gap);
(bot, bot + sub_h)
} else {
(y_center - slot_frac / 2.0, y_center + slot_frac / 2.0)
};
let px_y_top = computed.map_y(y_top);
let px_y_bot = computed.map_y(y_bot);
let px_rect_y = px_y_top.min(px_y_bot);
let px_rect_h = (px_y_top - px_y_bot).abs();
if left_val > 0.0 {
let left_color = if n_series == 1 {
series
.color
.as_deref()
.unwrap_or(&pp.left_color)
.to_string()
} else {
series_color.clone()
};
let px_x_left = computed.map_x(-left_val);
let bar_x = px_x_left.min(center_x);
let bar_w = (center_x - px_x_left).abs();
scene.add(Primitive::Rect {
x: bar_x,
y: px_rect_y,
width: bar_w,
height: px_rect_h,
fill: Color::from(left_color.as_str()),
stroke: None,
stroke_width: None,
opacity,
});
if pp.show_values && px_rect_h >= 10.0 && bar_w >= 16.0 {
let mid_y = px_rect_y + px_rect_h / 2.0 + 4.0;
let label = if pp.normalize {
format!("{:.1}%", left_val)
} else {
format!("{:.0}", left_val)
};
scene.add(Primitive::Text {
x: bar_x + 4.0,
y: mid_y,
content: label,
size: 9,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(Color::from("#ffffff")),
});
}
}
if right_val > 0.0 {
let right_color = if n_series == 1 {
series
.color
.as_deref()
.unwrap_or(&pp.right_color)
.to_string()
} else {
series_color.clone()
};
let px_x_right = computed.map_x(right_val);
let bar_x = center_x.min(px_x_right);
let bar_w = (px_x_right - center_x).abs();
scene.add(Primitive::Rect {
x: bar_x,
y: px_rect_y,
width: bar_w,
height: px_rect_h,
fill: Color::from(right_color.as_str()),
stroke: None,
stroke_width: None,
opacity,
});
if pp.show_values && px_rect_h >= 10.0 && bar_w >= 16.0 {
let mid_y = px_rect_y + px_rect_h / 2.0 + 4.0;
let label = if pp.normalize {
format!("{:.1}%", right_val)
} else {
format!("{:.0}", right_val)
};
scene.add(Primitive::Text {
x: bar_x + bar_w - 4.0,
y: mid_y,
content: label,
size: 9,
anchor: TextAnchor::End,
rotate: None,
bold: false,
color: Some(Color::from("#ffffff")),
});
}
}
}
}
let ox = computed.margin_left;
let pw = computed.width - computed.margin_left - computed.margin_right;
let top = computed.margin_top - 6.0;
if !pp.left_label.is_empty() {
scene.add(Primitive::Text {
x: ox + pw / 4.0,
y: top,
content: pp.left_label.clone(),
size: 12,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
}
if !pp.right_label.is_empty() {
scene.add(Primitive::Text {
x: ox + 3.0 * pw / 4.0,
y: top,
content: pp.right_label.clone(),
size: 12,
anchor: TextAnchor::Middle,
rotate: None,
bold: true,
color: None,
});
}
scene.add(Primitive::Line {
x1: center_x,
y1: computed.margin_top,
x2: center_x,
y2: computed.height - computed.margin_bottom,
stroke: Color::from("#aaaaaa"),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
pub fn render_pyramid(pp: PopulationPyramid, layout: Layout) -> Scene {
let plots = vec![Plot::Pyramid(pp)];
render_multiple(plots, layout)
}
pub fn waffle_largest_remainder(values: &[f64], total_cells: usize) -> Vec<usize> {
if values.is_empty() || total_cells == 0 {
return vec![0; values.len()];
}
let total: f64 = values.iter().sum();
if total <= 0.0 {
return vec![0; values.len()];
}
let exact: Vec<f64> = values
.iter()
.map(|v| v / total * total_cells as f64)
.collect();
let mut floored: Vec<usize> = exact.iter().map(|v| *v as usize).collect();
let allocated: usize = floored.iter().sum();
let remainder = total_cells.saturating_sub(allocated);
let mut fracs: Vec<(usize, f64)> = exact
.iter()
.zip(floored.iter())
.enumerate()
.map(|(i, (e, f))| (i, e - *f as f64))
.collect();
fracs.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
for i in 0..remainder {
floored[fracs[i].0] += 1;
}
floored
}
pub fn waffle_legend_label(
cat: &WaffleCategory,
cat_idx: usize,
total_val: f64,
cell_counts: &[usize],
wp: &WafflePlot,
) -> String {
let mut label = cat.label.clone();
let has_pct = wp.show_percents && total_val > 0.0;
let has_count = wp.show_counts;
match (has_pct, has_count) {
(true, true) => {
let pct = cat.value / total_val * 100.0;
label = format!("{} ({} cells, {:.1}%)", label, cell_counts[cat_idx], pct);
}
(true, false) => {
let pct = cat.value / total_val * 100.0;
label = format!("{} ({:.1}%)", label, pct);
}
(false, true) => {
label = format!("{} ({} cells)", label, cell_counts[cat_idx]);
}
(false, false) => {}
}
label
}
fn add_waffle(wp: &WafflePlot, scene: &mut Scene, computed: &ComputedLayout) {
let n_cells = wp.rows * wp.cols;
if n_cells == 0 {
return;
}
let values: Vec<f64> = wp.categories.iter().map(|c| c.value).collect();
let cell_counts = waffle_largest_remainder(&values, n_cells);
let mut assignments: Vec<Option<usize>> = Vec::with_capacity(n_cells);
for (cat_idx, &count) in cell_counts.iter().enumerate() {
for _ in 0..count {
assignments.push(Some(cat_idx));
}
}
while assignments.len() < n_cells {
assignments.push(None);
}
let plot_w = computed.plot_width();
let plot_h = computed.plot_height();
if plot_w <= 0.0 || plot_h <= 0.0 {
return;
}
let cell_px = (plot_w / wp.cols as f64).min(plot_h / wp.rows as f64);
let grid_w = cell_px * wp.cols as f64;
let grid_h = cell_px * wp.rows as f64;
let x0 = computed.margin_left + (plot_w - grid_w) * 0.5;
let y0 = computed.margin_top + (plot_h - grid_h) * 0.5;
let pad = cell_px * wp.gap * 0.5;
let inner = cell_px - 2.0 * pad;
if inner <= 0.0 {
return;
}
for (cell_idx, assignment) in assignments.iter().enumerate().take(n_cells) {
let (row, col) = match wp.fill_order {
FillOrder::RowMajorTopLeft => (cell_idx / wp.cols, cell_idx % wp.cols),
FillOrder::RowMajorBottomLeft => (wp.rows - 1 - cell_idx / wp.cols, cell_idx % wp.cols),
FillOrder::ColMajorTopLeft => (cell_idx % wp.rows, cell_idx / wp.rows),
FillOrder::ColMajorBottomLeft => (wp.rows - 1 - cell_idx % wp.rows, cell_idx / wp.rows),
};
let cx = x0 + col as f64 * cell_px + cell_px * 0.5;
let cy = y0 + row as f64 * cell_px + cell_px * 0.5;
let fill = Color::from(match assignment {
Some(i) => wp.categories[*i].color.as_str(),
None => wp.empty_color.as_str(),
});
match wp.shape {
CellShape::Square => {
scene.add(Primitive::Rect {
x: cx - cell_px * 0.5 + pad,
y: cy - cell_px * 0.5 + pad,
width: inner,
height: inner,
fill,
stroke: None,
stroke_width: None,
opacity: None,
});
}
CellShape::Circle => {
scene.add(Primitive::Circle {
cx,
cy,
r: inner * 0.5,
fill,
fill_opacity: None,
stroke: None,
stroke_width: None,
});
}
}
}
if let Some(ref label) = wp.unit_label {
let label_y = y0 + grid_h + computed.body_size as f64 + 4.0;
let label_x = x0 + grid_w * 0.5;
scene.add(Primitive::Text {
x: label_x,
y: label_y,
content: label.clone(),
size: computed.body_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: Some(Color::from("#888888")),
});
}
}
pub fn render_waffle(wp: WafflePlot, layout: Layout) -> Scene {
let plots = vec![Plot::Waffle(wp)];
render_multiple(plots, layout)
}
fn parse_hex_color(hex: &str) -> (u8, u8, u8) {
let h = hex.trim_start_matches('#');
match h.len() {
6 => {
let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(66);
let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(146);
let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(198);
(r, g, b)
}
3 => {
let r = u8::from_str_radix(&h[0..1], 16)
.unwrap_or(4)
.wrapping_mul(17);
let g = u8::from_str_radix(&h[1..2], 16)
.unwrap_or(9)
.wrapping_mul(17);
let b = u8::from_str_radix(&h[2..3], 16)
.unwrap_or(12)
.wrapping_mul(17);
(r, g, b)
}
_ => (66, 146, 198),
}
}
fn add_horizon(hp: &HorizonPlot, scene: &mut Scene, computed: &ComputedLayout) {
let n = hp.series.len();
if n == 0 {
return;
}
if hp.n_bands == 0 {
return;
}
let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
let pos_bw = hp.pos_band_width();
let neg_bw = hp.neg_band_width();
let mut rb = ryu::Buffer::new();
for (i, series) in hp.series.iter().enumerate() {
if series.x.is_empty() || series.y.is_empty() {
continue;
}
let pts = series.x.len().min(series.y.len());
let y_center_data = (n - i) as f64;
let y_center_px = computed.map_y(y_center_data);
let row_baseline_px = y_center_px + cell_h_px * 0.5;
let row_h_px = cell_h_px;
let (pr, pg, pb) = parse_hex_color(&series.pos_color);
for band in 1..=hp.n_bands {
let band_lo = (band - 1) as f64 * pos_bw;
let band_hi = band as f64 * pos_bw;
let alpha = band as f64 / hp.n_bands as f64;
let color_str = format!("rgb({},{},{})", pr, pg, pb);
let mut has_fill = false;
let mut path = String::with_capacity(pts * 24);
let any_pos = (0..pts).any(|j| {
let raw_v = (series.y[j] - hp.baseline).max(0.0);
(raw_v - band_lo) > 1e-12
});
if !any_pos {
continue;
}
for j in 0..pts {
let raw_v = (series.y[j] - hp.baseline).max(0.0);
let in_band = (raw_v - band_lo).clamp(0.0, pos_bw).min(band_hi - band_lo);
let px_h = if pos_bw > 0.0 {
in_band / pos_bw * row_h_px
} else {
0.0
};
let sx = round2(computed.map_x(series.x[j]));
let sy = round2(row_baseline_px - px_h);
if !has_fill {
path.push('M');
has_fill = true;
} else {
path.push('L');
}
path.push(' ');
path.push_str(rb.format(sx));
path.push(' ');
path.push_str(rb.format(sy));
path.push(' ');
}
if !has_fill {
continue;
}
let last_x = round2(computed.map_x(series.x[pts - 1]));
let first_x = round2(computed.map_x(series.x[0]));
let base_y = round2(row_baseline_px);
{
let s_last_x = rb.format(last_x).to_string();
let s_base_y1 = rb.format(base_y).to_string();
let s_first_x = rb.format(first_x).to_string();
let s_base_y2 = rb.format(base_y).to_string();
path.push_str(&format!(
"L {} {} L {} {} Z",
s_last_x, s_base_y1, s_first_x, s_base_y2
));
}
scene.add(Primitive::Path(Box::new(PathData {
d: path,
fill: Some(Color::from(color_str.as_str())),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(alpha),
stroke_dasharray: None,
})));
}
let has_negatives = series.y.iter().any(|&v| v < hp.baseline);
if has_negatives {
let (nr, ng, nb) = parse_hex_color(&series.neg_color);
for band in 1..=hp.n_bands {
let band_lo = (band - 1) as f64 * neg_bw;
let alpha = band as f64 / hp.n_bands as f64;
let color_str = format!("rgb({},{},{})", nr, ng, nb);
let mut has_fill = false;
let mut path = String::with_capacity(pts * 24);
let any_neg = (0..pts).any(|j| {
let raw_v = (hp.baseline - series.y[j]).max(0.0);
(raw_v - band_lo) > 1e-12
});
if !any_neg {
continue;
}
for j in 0..pts {
let raw_v = (hp.baseline - series.y[j]).max(0.0);
let in_band = (raw_v - band_lo).clamp(0.0, neg_bw);
let px_h = if neg_bw > 0.0 {
in_band / neg_bw * row_h_px
} else {
0.0
};
let sx = round2(computed.map_x(series.x[j]));
let sy = round2(row_baseline_px - px_h);
if !has_fill {
path.push('M');
has_fill = true;
} else {
path.push('L');
}
path.push(' ');
path.push_str(rb.format(sx));
path.push(' ');
path.push_str(rb.format(sy));
path.push(' ');
}
if !has_fill {
continue;
}
let last_x = round2(computed.map_x(series.x[pts - 1]));
let first_x = round2(computed.map_x(series.x[0]));
let base_y = round2(row_baseline_px);
{
let s_last_x = rb.format(last_x).to_string();
let s_base_y1 = rb.format(base_y).to_string();
let s_first_x = rb.format(first_x).to_string();
let s_base_y2 = rb.format(base_y).to_string();
path.push_str(&format!(
"L {} {} L {} {} Z",
s_last_x, s_base_y1, s_first_x, s_base_y2
));
}
scene.add(Primitive::Path(Box::new(PathData {
d: path,
fill: Some(Color::from(color_str.as_str())),
stroke: Color::from("none"),
stroke_width: 0.0,
opacity: Some(alpha),
stroke_dasharray: None,
})));
}
}
let sep_y = round2(row_baseline_px);
scene.add(Primitive::Line {
x1: computed.margin_left,
y1: sep_y,
x2: computed.width - computed.margin_right,
y2: sep_y,
stroke: Color::from(&computed.theme.axis_color),
stroke_width: computed.axis_stroke_width * 0.5,
stroke_dasharray: None,
});
}
}
fn add_horizon_annots(hp: &HorizonPlot, scene: &mut Scene, computed: &ComputedLayout) {
if !hp.show_value_labels {
return;
}
let n = hp.series.len();
if n == 0 {
return;
}
let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
let pos_bw = hp.pos_band_width();
let neg_bw = hp.neg_band_width();
let annot_x = computed.width - computed.margin_right + 6.0;
let font_size = computed.tick_size;
let axis_color = Color::from(&computed.theme.axis_color);
for (i, series) in hp.series.iter().enumerate() {
if series.x.is_empty() || series.y.is_empty() {
continue;
}
let y_center_data = (n - i) as f64;
let y_center_px = computed.map_y(y_center_data);
let row_baseline_px = y_center_px + cell_h_px * 0.5;
let row_h_px = cell_h_px;
let pos_scale = pos_bw * hp.n_bands as f64;
let has_pos = series.y.iter().any(|&v| v > hp.baseline + 1e-12);
let has_neg = series.y.iter().any(|&v| v < hp.baseline - 1e-12);
let (pos_y, neg_y) = if has_pos && has_neg {
(
row_baseline_px - row_h_px * 0.78,
row_baseline_px - row_h_px * 0.22,
)
} else {
(
row_baseline_px - row_h_px * 0.5,
row_baseline_px - row_h_px * 0.5,
)
};
if has_pos {
let val_str = TickFormat::Auto.format(pos_scale);
if hp.show_sign_colors {
let (pr, pg, pb) = parse_hex_color(&series.pos_color);
let sign_color = Color::Rgb(pr, pg, pb);
scene.add(Primitive::Text {
x: annot_x,
y: pos_y,
content: "+".to_string(),
size: font_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(sign_color),
});
let char_w = font_size as f64 * 0.65;
scene.add(Primitive::Text {
x: annot_x + char_w,
y: pos_y,
content: val_str,
size: font_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(axis_color.clone()),
});
} else {
scene.add(Primitive::Text {
x: annot_x,
y: pos_y,
content: format!("+{}", val_str),
size: font_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(axis_color.clone()),
});
}
}
if has_neg {
let neg_scale = neg_bw * hp.n_bands as f64;
let val_str = TickFormat::Auto.format(neg_scale);
if hp.show_sign_colors {
let (nr, ng, nb) = parse_hex_color(&series.neg_color);
let sign_color = Color::Rgb(nr, ng, nb);
scene.add(Primitive::Text {
x: annot_x,
y: neg_y,
content: "-".to_string(),
size: font_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(sign_color),
});
let char_w = font_size as f64 * 0.65;
scene.add(Primitive::Text {
x: annot_x + char_w,
y: neg_y,
content: val_str,
size: font_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(axis_color.clone()),
});
} else {
scene.add(Primitive::Text {
x: annot_x,
y: neg_y,
content: format!("-{}", val_str),
size: font_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: Some(axis_color.clone()),
});
}
}
}
}
pub fn render_horizon(hp: HorizonPlot, layout: Layout) -> Scene {
let plots = vec![Plot::Horizon(hp)];
render_multiple(plots, layout)
}
fn add_gantt(gp: &GanttPlot, scene: &mut Scene, computed: &ComputedLayout) {
let display_rows = gp.ordered_display_rows();
let n = display_rows.len();
if n == 0 {
return;
}
let cat10 = Palette::category10();
let groups = gp.effective_group_order();
let group_color_idx: std::collections::HashMap<String, usize> = groups
.iter()
.flatten()
.enumerate()
.map(|(ci, name)| (name.clone(), ci))
.collect();
let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
let bar_h = cell_h_px * gp.bar_height_frac;
let has_groups = groups.iter().any(|g| g.is_some());
let plot_left = computed.map_x(computed.x_range.0);
let plot_right = computed.map_x(computed.x_range.1);
for (row_i, row) in display_rows.iter().enumerate() {
let y_data = (n - row_i) as f64;
let y_center = computed.map_y(y_data);
match row {
GanttDisplayRow::GroupHeader(group_name) => {
let band_y = y_center - cell_h_px * 0.5;
scene.add(Primitive::Rect {
x: plot_left,
y: band_y,
width: (plot_right - plot_left).max(0.0),
height: cell_h_px,
fill: Color::from(&gp.group_bg),
stroke: None,
stroke_width: None,
opacity: None,
});
let _ = group_name;
}
GanttDisplayRow::Task(task_idx) => {
let task = &gp.tasks[*task_idx];
let color_str = if let Some(ref c) = task.color {
c.as_str().to_string()
} else if has_groups {
if let Some(ref g) = task.group {
let idx = group_color_idx.get(g).copied().unwrap_or(0);
cat10[idx % cat10.len()].to_string()
} else {
gp.color.clone()
}
} else {
gp.color.clone()
};
let bar_color = Color::from(color_str.as_str());
if task.is_milestone {
let cx = computed.map_x(task.start);
let s = gp.milestone_size;
let d = format!(
"M {},{} L {},{} L {},{} L {},{} Z",
round2(cx),
round2(y_center - s),
round2(cx + s),
round2(y_center),
round2(cx),
round2(y_center + s),
round2(cx - s),
round2(y_center),
);
scene.add(Primitive::Path(Box::new(PathData {
d,
fill: Some(bar_color.clone()),
stroke: bar_color.clone(),
stroke_width: 1.0,
opacity: None,
stroke_dasharray: None,
})));
} else {
let x_start = computed.map_x(task.start);
let x_end = computed.map_x(task.end);
let bar_width = (x_end - x_start).max(2.0);
let bar_y = y_center - bar_h * 0.5;
scene.add(Primitive::Rect {
x: x_start,
y: bar_y,
width: bar_width,
height: bar_h,
fill: bar_color.clone(),
stroke: None,
stroke_width: None,
opacity: Some(0.85),
});
if let Some(frac) = task.progress {
let prog_w = bar_width * frac;
if prog_w > 0.0 {
scene.add(Primitive::Rect {
x: x_start,
y: bar_y,
width: prog_w,
height: bar_h,
fill: bar_color.clone(),
stroke: None,
stroke_width: None,
opacity: Some(1.0),
});
scene.add(Primitive::Line {
x1: x_start + prog_w,
y1: bar_y,
x2: x_start + prog_w,
y2: bar_y + bar_h,
stroke: Color::from("rgba(0,0,0,0.25)"),
stroke_width: 1.0,
stroke_dasharray: None,
});
}
}
scene.add(Primitive::Rect {
x: x_start,
y: bar_y,
width: bar_width,
height: bar_h,
fill: Color::from("none"),
stroke: Some(Color::from("rgba(0,0,0,0.18)")),
stroke_width: Some(0.8),
opacity: None,
});
if gp.show_labels {
let label_size = 11u32;
let est_text_w = task.label.len() as f64 * label_size as f64 * 0.55;
if bar_width >= gp.label_min_width && bar_width > est_text_w + 6.0 {
scene.add(Primitive::Text {
x: x_start + bar_width * 0.5,
y: y_center + label_size as f64 * 0.35,
content: task.label.clone(),
size: label_size,
anchor: TextAnchor::Middle,
rotate: None,
bold: false,
color: Some(Color::from("white")),
});
}
}
}
}
}
}
if let Some(now) = gp.now_line {
let nx = computed.map_x(now);
scene.add(Primitive::Line {
x1: nx,
y1: computed.map_y(n as f64 + 0.5),
x2: nx,
y2: computed.map_y(0.5),
stroke: Color::from("#cc3333"),
stroke_width: 1.5,
stroke_dasharray: Some("5,3".into()),
});
}
}
fn add_gantt_labels(gp: &GanttPlot, scene: &mut Scene, computed: &ComputedLayout) {
if !gp.show_labels {
return;
}
let display_rows = gp.ordered_display_rows();
let n = display_rows.len();
if n == 0 {
return;
}
let cat10 = Palette::category10();
let groups = gp.effective_group_order();
let group_color_idx: std::collections::HashMap<String, usize> = groups
.iter()
.flatten()
.enumerate()
.map(|(ci, name)| (name.clone(), ci))
.collect();
let has_groups = groups.iter().any(|g| g.is_some());
let label_size = 11u32;
let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
let _ = cell_h_px;
for (row_i, row) in display_rows.iter().enumerate() {
let y_data = (n - row_i) as f64;
let y_center = computed.map_y(y_data);
if let GanttDisplayRow::Task(task_idx) = row {
let task = &gp.tasks[*task_idx];
let color_str = if let Some(ref c) = task.color {
c.as_str().to_string()
} else if has_groups {
if let Some(ref g) = task.group {
let idx = group_color_idx.get(g).copied().unwrap_or(0);
cat10[idx % cat10.len()].to_string()
} else {
gp.color.clone()
}
} else {
gp.color.clone()
};
if task.is_milestone {
let cx = computed.map_x(task.start);
let s = gp.milestone_size;
let text_color = Color::from(color_str.as_str());
scene.add(Primitive::Text {
x: cx + s + 5.0,
y: y_center + label_size as f64 * 0.35,
content: task.label.clone(),
size: label_size,
anchor: TextAnchor::Start,
rotate: None,
bold: true,
color: Some(text_color),
});
} else {
let x_start = computed.map_x(task.start);
let x_end = computed.map_x(task.end);
let bar_width = (x_end - x_start).max(2.0);
let est_text_w = task.label.len() as f64 * label_size as f64 * 0.55;
if !(bar_width >= gp.label_min_width && bar_width > est_text_w + 6.0) {
scene.add(Primitive::Text {
x: x_end + 5.0,
y: y_center + label_size as f64 * 0.35,
content: task.label.clone(),
size: label_size,
anchor: TextAnchor::Start,
rotate: None,
bold: false,
color: None,
});
}
}
}
}
}
pub fn render_gantt(gp: GanttPlot, layout: Layout) -> Scene {
let plots = vec![Plot::Gantt(gp)];
render_multiple(plots, layout)
}