use uzor::render::{RenderContext, TextAlign, TextBaseline};
use uzor::types::Rect;
use crate::coord::PlotArea;
use crate::figure::FigureOverlay;
use crate::mark::area::draw_area;
use crate::mark::line::draw_polyline;
use crate::mark::MarkStyle;
use crate::scale::{LinearScale, NumberFormat};
use crate::theme::FigureTheme;
const PAD: f64 = 12.0;
const LABEL_TO_VALUE_GAP: f64 = 4.0;
const VALUE_TO_DELTA_GAP: f64 = 4.0;
const DELTA_TO_SPARK_GAP: f64 = 8.0;
const SPARKLINE_HEIGHT_FRACTION: f64 = 0.32;
const SPARKLINE_MIN_HEIGHT: f64 = 16.0;
const SPARKLINE_FILL_ALPHA: f64 = 0.18;
const DELTA_TRIANGLE_SIZE: f64 = 6.0;
const DELTA_TRIANGLE_GAP: f64 = 4.0;
const HOVER_BORDER_ALPHA: f64 = 0.5;
pub struct KpiFigure {
pub label: String,
pub value: f64,
pub previous_value: Option<f64>,
pub sparkline: Vec<f64>,
format: NumberFormat,
}
impl KpiFigure {
pub fn new(label: impl Into<String>, value: f64) -> Self {
Self { label: label.into(), value, previous_value: None, sparkline: Vec::new(), format: NumberFormat::default() }
}
pub fn with_previous_value(mut self, previous: f64) -> Self {
self.previous_value = Some(previous);
self
}
pub fn with_sparkline(mut self, values: Vec<f64>) -> Self {
self.sparkline = values;
self
}
pub fn with_format(mut self, format: NumberFormat) -> Self {
self.format = format;
self
}
pub fn delta_pct(&self) -> Option<f64> {
let previous = self.previous_value?;
if previous == 0.0 {
return None;
}
Some((self.value - previous) / previous.abs() * 100.0)
}
fn has_sparkline(&self) -> bool {
self.sparkline.len() >= 2
}
fn scaled_font(family: &str, size_px: f64, bold: bool) -> String {
if bold {
format!("bold {size_px}px {family}")
} else {
format!("{size_px}px {family}")
}
}
pub fn render(&self, ctx: &mut dyn RenderContext, rect: Rect, theme: &FigureTheme) {
self.render_with(ctx, rect, theme, &FigureOverlay::default());
}
pub fn render_with(&self, ctx: &mut dyn RenderContext, rect: Rect, theme: &FigureTheme, overlay: &FigureOverlay<'_>) {
ctx.set_fill_color(&theme.background);
ctx.fill_rect(rect.x, rect.y, rect.width, rect.height);
let inner = Rect::new(rect.x + PAD, rect.y + PAD, (rect.width - PAD * 2.0).max(0.0), (rect.height - PAD * 2.0).max(0.0));
let spark_h = if self.has_sparkline() { (inner.height * SPARKLINE_HEIGHT_FRACTION).max(SPARKLINE_MIN_HEIGHT) } else { 0.0 };
let text_h = (inner.height - spark_h - if self.has_sparkline() { DELTA_TO_SPARK_GAP } else { 0.0 }).max(0.0);
let label_font = theme.label_font.clone();
let value_font_size = (text_h * 0.42).clamp(16.0, 40.0);
let value_font = Self::scaled_font(&theme.label_font_family, value_font_size, true);
let delta_font_size = (text_h * 0.16).clamp(11.0, 16.0);
let delta_font = Self::scaled_font(&theme.label_font_family, delta_font_size, false);
let mut cursor_y = inner.y;
ctx.set_font(&label_font);
ctx.set_fill_color(&theme.label_color);
ctx.set_text_align(TextAlign::Left);
ctx.set_text_baseline(TextBaseline::Top);
ctx.fill_text(&self.label, inner.x, cursor_y);
let label_h = ctx.text_bounds(&self.label, &label_font).h.max(12.0);
cursor_y += label_h + LABEL_TO_VALUE_GAP;
let step = self.sparkline.iter().chain(std::iter::once(&self.value)).copied().fold(1.0_f64, |acc, v| acc.max(v.abs())).max(1.0) / 100.0;
let value_text = self.format.format(self.value, step);
ctx.set_font(&value_font);
ctx.set_fill_color(&theme.label_color);
ctx.set_text_align(TextAlign::Left);
ctx.set_text_baseline(TextBaseline::Top);
ctx.fill_text(&value_text, inner.x, cursor_y);
let value_h = ctx.text_bounds(&value_text, &value_font).h.max(value_font_size);
cursor_y += value_h + VALUE_TO_DELTA_GAP;
if let Some(delta) = self.delta_pct() {
let (color, text_x) = if delta > 0.0 {
let tri_cy = cursor_y + delta_font_size * 0.5;
draw_delta_triangle(ctx, inner.x + DELTA_TRIANGLE_SIZE, tri_cy, DELTA_TRIANGLE_SIZE, true, &theme.positive);
(&theme.positive, inner.x + DELTA_TRIANGLE_SIZE * 2.0 + DELTA_TRIANGLE_GAP)
} else if delta < 0.0 {
let tri_cy = cursor_y + delta_font_size * 0.5;
draw_delta_triangle(ctx, inner.x + DELTA_TRIANGLE_SIZE, tri_cy, DELTA_TRIANGLE_SIZE, false, &theme.negative);
(&theme.negative, inner.x + DELTA_TRIANGLE_SIZE * 2.0 + DELTA_TRIANGLE_GAP)
} else {
(&theme.label_color, inner.x)
};
let delta_text = if delta > 0.0 { format!("+{delta:.1}%") } else { format!("{delta:.1}%") };
ctx.set_font(&delta_font);
ctx.set_fill_color(color);
ctx.set_text_align(TextAlign::Left);
ctx.set_text_baseline(TextBaseline::Top);
ctx.fill_text(&delta_text, text_x, cursor_y);
}
if self.has_sparkline() {
let spark_rect = Rect::new(inner.x, inner.bottom() - spark_h, inner.width, spark_h);
let area = PlotArea::new(spark_rect);
let x_scale = LinearScale::new(0.0, (self.sparkline.len() - 1) as f64);
let (y_min, y_max) = self.sparkline.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), &v| (mn.min(v), mx.max(v)));
let y_scale = LinearScale::new(y_min, y_max);
let points: Vec<(f64, f64)> = self.sparkline.iter().enumerate().map(|(i, &v)| (i as f64, v)).collect();
let trending_up = self.sparkline.last().copied().unwrap_or(0.0) >= self.sparkline.first().copied().unwrap_or(0.0);
let color = if trending_up { theme.positive.clone() } else { theme.negative.clone() };
let fill_style = MarkStyle { color: color.clone(), fill_alpha: SPARKLINE_FILL_ALPHA, ..Default::default() };
let line_style = MarkStyle { color, stroke_width: 1.5, ..Default::default() };
draw_area(ctx, &area, &x_scale, &y_scale, &points, &fill_style);
draw_polyline(ctx, &area, &x_scale, &y_scale, &points, &line_style);
}
if let Some((hx, hy)) = overlay.hover_px {
if hx >= rect.x && hx <= rect.right() && hy >= rect.y && hy <= rect.bottom() {
ctx.set_stroke_color(&theme.palette[0]);
ctx.set_stroke_width(1.5);
ctx.set_global_alpha(HOVER_BORDER_ALPHA);
ctx.stroke_rect(rect.x + 0.75, rect.y + 0.75, (rect.width - 1.5).max(0.0), (rect.height - 1.5).max(0.0));
ctx.set_global_alpha(1.0);
}
}
}
}
fn draw_delta_triangle(ctx: &mut dyn RenderContext, cx: f64, cy: f64, size: f64, up: bool, color: &str) {
ctx.set_fill_color(color);
ctx.begin_path();
if up {
ctx.move_to(cx, cy - size);
ctx.line_to(cx + size, cy + size * 0.6);
ctx.line_to(cx - size, cy + size * 0.6);
} else {
ctx.move_to(cx, cy + size);
ctx.line_to(cx + size, cy - size * 0.6);
ctx.line_to(cx - size, cy - size * 0.6);
}
ctx.close_path();
ctx.fill();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delta_pct_computes_the_signed_percent_change() {
let up = KpiFigure::new("Revenue", 120.0).with_previous_value(100.0);
assert!((up.delta_pct().unwrap() - 20.0).abs() < 1e-9);
let down = KpiFigure::new("Churn", 80.0).with_previous_value(100.0);
assert!((down.delta_pct().unwrap() - (-20.0)).abs() < 1e-9);
}
#[test]
fn delta_pct_is_none_without_a_previous_value() {
assert!(KpiFigure::new("Revenue", 120.0).delta_pct().is_none());
}
#[test]
fn delta_pct_is_none_against_a_zero_previous_value() {
assert!(KpiFigure::new("Revenue", 120.0).with_previous_value(0.0).delta_pct().is_none());
}
#[test]
fn delta_pct_handles_a_negative_previous_value_via_the_absolute_denominator() {
let figure = KpiFigure::new("Net", -25.0).with_previous_value(-50.0);
assert!((figure.delta_pct().unwrap() - 50.0).abs() < 1e-9);
}
#[test]
fn scaled_font_composes_the_given_family_at_the_requested_size_and_weight() {
assert_eq!(KpiFigure::scaled_font("sans-serif", 28.0, true), "bold 28px sans-serif");
assert_eq!(KpiFigure::scaled_font("sans-serif", 13.0, false), "13px sans-serif");
}
#[test]
fn scaled_font_no_longer_re_parses_a_composed_css_font_string_bug_fix() {
assert_eq!(KpiFigure::scaled_font("Georgia", 28.0, true), "bold 28px Georgia");
assert_eq!(KpiFigure::scaled_font("'Times New Roman', serif", 13.0, false), "13px 'Times New Roman', serif");
}
#[test]
fn render_smoke_with_a_non_trivial_theme_font_string_and_family() {
use uzor_export::{render_to_png, ExportSpec};
let mut theme = FigureTheme::dark();
theme.label_font = "bold 11px Georgia".to_owned();
theme.label_font_family = "Georgia".to_owned();
let figure = KpiFigure::new("Revenue", 128_400.0).with_previous_value(110_000.0);
let spec = ExportSpec { width_px: 220, height_px: 120, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| figure.render(ctx, Rect::new(0.0, 0.0, 220.0, 120.0), &theme));
assert!(result.is_ok());
}
#[test]
fn render_smoke_with_and_without_sparkline_and_previous_value() {
use uzor_export::{render_to_png, ExportSpec};
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 220, height_px: 120, dpr: 1.0, background: None };
let rect = Rect::new(0.0, 0.0, 220.0, 120.0);
let bare = KpiFigure::new("Active Users", 48213.0);
let result = render_to_png(&spec, |ctx| bare.render(ctx, rect, &theme));
assert!(result.is_ok());
let with_delta = KpiFigure::new("Revenue", 128_400.0).with_previous_value(110_000.0).with_format(NumberFormat::Currency("$"));
let result = render_to_png(&spec, |ctx| with_delta.render(ctx, rect, &theme));
assert!(result.is_ok());
let sparkline: Vec<f64> = (0..20).map(|i| 100.0 + ((i * 7) % 15) as f64).collect();
let full =
KpiFigure::new("Signups", 812.0).with_previous_value(790.0).with_sparkline(sparkline).with_format(NumberFormat::Si);
let overlay = FigureOverlay { hover_px: Some((100.0, 60.0)), brush: None, focus: None };
let result = render_to_png(&spec, |ctx| full.render_with(ctx, rect, &theme, &overlay));
assert!(result.is_ok());
}
#[test]
fn a_flat_delta_uses_the_neutral_label_color_not_positive_or_negative() {
use uzor_export::{render_to_png, ExportSpec};
let figure = KpiFigure::new("Steady", 100.0).with_previous_value(100.0);
assert!((figure.delta_pct().unwrap()).abs() < 1e-9);
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 220, height_px: 120, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| figure.render(ctx, Rect::new(0.0, 0.0, 220.0, 120.0), &theme));
assert!(result.is_ok());
}
}