use std::cmp::{max, min};
use std::sync::OnceLock;
use ab_glyph::{point, Font, FontArc, Glyph, PxScale, PxScaleFont, ScaleFont};
use crate::color_scheme::Rgba;
const FONT_DATA: &[u8] = include_bytes!("../fonts/RobotoMono-SemiBold.ttf");
static FONT: OnceLock<FontArc> = OnceLock::new();
fn default_font() -> &'static FontArc {
FONT.get_or_init(|| FontArc::try_from_slice(FONT_DATA).expect("failed to load embedded font"))
}
const DEFAULT_PADDING: u32 = 24;
const TICK_LABEL_GAP: u32 = 6;
const AXIS_LABEL_GAP: u32 = 10;
const TITLE_GAP: u32 = 12;
const DEFAULT_TITLE_FONT_SIZE: f32 = 28.0;
const DEFAULT_TICK_DECIMALS: usize = 2;
const MIN_TICK_COUNT: usize = 2;
#[derive(Clone, Copy, Debug)]
pub enum BorderColor {
White,
Black,
}
impl BorderColor {
fn background_rgba(&self) -> Rgba {
match self {
BorderColor::White => [255, 255, 255, 255],
BorderColor::Black => [0, 0, 0, 255],
}
}
fn foreground_rgba(&self) -> Rgba {
match self {
BorderColor::White => [0, 0, 0, 255],
BorderColor::Black => [255, 255, 255, 255],
}
}
}
#[derive(Clone, Debug)]
pub struct ChartTitle {
pub text: String,
pub font_size: f32,
}
impl ChartTitle {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
font_size: DEFAULT_TITLE_FONT_SIZE,
}
}
}
impl Default for ChartTitle {
fn default() -> Self {
Self::new("")
}
}
#[derive(Clone, Debug)]
pub struct AxisConfig {
pub label: String,
pub units: Option<String>,
pub min: f32,
pub max: f32,
pub tick_count: usize,
pub tick_length: u32,
pub label_font_size: f32,
pub tick_label_font_size: f32,
pub decimal_places: usize,
}
impl AxisConfig {
pub fn new(label: impl Into<String>, min: f32, max: f32) -> Self {
Self {
label: label.into(),
units: None,
min,
max,
tick_count: 5,
tick_length: 12,
label_font_size: 20.0,
tick_label_font_size: 14.0,
decimal_places: DEFAULT_TICK_DECIMALS,
}
}
pub fn with_units(mut self, units: impl Into<String>) -> Self {
self.units = Some(units.into());
self
}
pub fn with_tick_count(mut self, tick_count: usize) -> Self {
self.tick_count = tick_count.max(MIN_TICK_COUNT);
self
}
pub fn with_tick_length(mut self, tick_length: u32) -> Self {
self.tick_length = tick_length.max(4);
self
}
pub fn with_label_font_size(mut self, size: f32) -> Self {
self.label_font_size = size.max(8.0);
self
}
pub fn with_tick_label_font_size(mut self, size: f32) -> Self {
self.tick_label_font_size = size.max(6.0);
self
}
pub fn with_decimal_places(mut self, places: usize) -> Self {
self.decimal_places = places.min(6);
self
}
}
#[derive(Clone, Debug)]
pub struct ChartAnnotations {
pub title: Option<ChartTitle>,
pub x_axis: Option<AxisConfig>,
pub y_axis: Option<AxisConfig>,
pub border_color: BorderColor,
pub padding: u32,
}
impl Default for ChartAnnotations {
fn default() -> Self {
Self {
title: None,
x_axis: None,
y_axis: None,
border_color: BorderColor::White,
padding: DEFAULT_PADDING,
}
}
}
#[derive(Clone, Debug)]
pub struct ChartImage {
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Copy, Debug, Default)]
struct TextMetrics {
width: u32,
height: u32,
}
struct AxisTickLayout {
ratio: f32,
label: String,
metrics: TextMetrics,
}
struct AxisLabelLayout {
text: String,
metrics: TextMetrics,
}
struct XAxisLayout {
ticks: Vec<AxisTickLayout>,
label: Option<AxisLabelLayout>,
tick_length: u32,
}
struct YAxisLayout {
ticks: Vec<AxisTickLayout>,
label: Option<AxisLabelLayout>,
rotated_label_width: u32,
rotated_label_height: u32,
tick_length: u32,
max_tick_label_width: u32,
}
struct LayoutContext {
border_bg: Rgba,
border_fg: Rgba,
padding: u32,
}
struct PositionedGlyphs {
glyphs: Vec<Glyph>,
caret_end: f32,
baseline_y: f32,
}
fn layout_text_with_scaled(
scaled_font: &PxScaleFont<&FontArc>,
text: &str,
origin_x: f32,
) -> PositionedGlyphs {
let baseline_y = scaled_font.ascent();
let mut glyphs = Vec::new();
let mut caret = origin_x;
let mut prev = None;
for ch in text.chars() {
let mut glyph = scaled_font.scaled_glyph(ch);
if let Some(prev_id) = prev {
caret += scaled_font.kern(prev_id, glyph.id);
}
glyph.position = point(caret, baseline_y);
caret += scaled_font.h_advance(glyph.id);
prev = Some(glyph.id);
glyphs.push(glyph);
}
PositionedGlyphs {
glyphs,
caret_end: caret,
baseline_y,
}
}
fn compute_bounds(
scaled_font: &PxScaleFont<&FontArc>,
layout: &PositionedGlyphs,
) -> (f32, f32, f32, f32) {
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
for glyph in &layout.glyphs {
if let Some(outlined) = scaled_font.outline_glyph(glyph.clone()) {
let bounds = outlined.px_bounds();
min_x = min_x.min(bounds.min.x);
min_y = min_y.min(bounds.min.y);
max_x = max_x.max(bounds.max.x);
max_y = max_y.max(bounds.max.y);
}
}
if min_x == f32::MAX {
let ascent = scaled_font.ascent();
let descent = scaled_font.descent();
min_x = 0.0;
min_y = layout.baseline_y - ascent;
max_x = layout.caret_end;
max_y = layout.baseline_y - descent;
}
(min_x, min_y, max_x, max_y)
}
fn measure_text(text: &str, font_size: f32) -> TextMetrics {
if text.is_empty() {
return TextMetrics::default();
}
let font = default_font();
let scale = PxScale::from(font_size);
let scaled_font = font.as_scaled(scale);
let layout = layout_text_with_scaled(&scaled_font, text, 0.0);
let (min_x, min_y, max_x, max_y) = compute_bounds(&scaled_font, &layout);
let width = (max_x - min_x).ceil().max(0.0) as u32;
let height = (max_y - min_y).ceil().max(0.0) as u32;
TextMetrics { width, height }
}
fn render_text_bitmap(text: &str, font_size: f32) -> Option<(Vec<f32>, u32, u32)> {
if text.is_empty() {
return None;
}
let font = default_font();
let scale = PxScale::from(font_size);
let scaled_font = font.as_scaled(scale);
let layout = layout_text_with_scaled(&scaled_font, text, 0.0);
let (min_x, min_y, max_x, max_y) = compute_bounds(&scaled_font, &layout);
let width = (max_x - min_x).ceil().max(1.0) as u32;
let height = (max_y - min_y).ceil().max(1.0) as u32;
let offset_x = -min_x;
let offset_y = -min_y;
let mut pixels = vec![0.0f32; (width * height) as usize];
let PositionedGlyphs { glyphs, .. } = layout;
for glyph in glyphs {
let mut shifted = glyph.clone();
shifted.position.x += offset_x;
shifted.position.y += offset_y;
if let Some(outlined) = scaled_font.outline_glyph(shifted) {
let bounds = outlined.px_bounds();
let base_x = bounds.min.x as i32;
let base_y = bounds.min.y as i32;
outlined.draw(|x, y, coverage| {
if coverage == 0.0 {
return;
}
let px = base_x + x as i32;
let py = base_y + y as i32;
if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height {
let idx = (py as u32 * width + px as u32) as usize;
pixels[idx] = pixels[idx].max(coverage);
}
});
}
}
Some((pixels, width, height))
}
fn blend_pixel(dest: &mut [u8], src: Rgba, alpha: f32) {
let src_alpha = (src[3] as f32 / 255.0) * alpha;
let inv_alpha = 1.0 - src_alpha;
for i in 0..3 {
let blended = src[i] as f32 * src_alpha + dest[i] as f32 * inv_alpha;
dest[i] = blended.round().clamp(0.0, 255.0) as u8;
}
dest[3] = 255;
}
fn draw_text(
buffer: &mut [u8],
width: u32,
height: u32,
left: i32,
top: i32,
text: &str,
font_size: f32,
color: Rgba,
) {
if text.is_empty() {
return;
}
let font = default_font();
let scale = PxScale::from(font_size);
let scaled_font = font.as_scaled(scale);
let layout = layout_text_with_scaled(&scaled_font, text, 0.0);
let (min_x, min_y, _, _) = compute_bounds(&scaled_font, &layout);
let translate_x = left as f32 - min_x;
let translate_y = top as f32 - min_y;
let PositionedGlyphs { glyphs, .. } = layout;
for glyph in glyphs {
let mut shifted = glyph.clone();
shifted.position.x += translate_x;
shifted.position.y += translate_y;
if let Some(outlined) = scaled_font.outline_glyph(shifted) {
let bounds = outlined.px_bounds();
let base_x = bounds.min.x as i32;
let base_y = bounds.min.y as i32;
outlined.draw(|x, y, coverage| {
if coverage == 0.0 {
return;
}
let px = base_x + x as i32;
let py = base_y + y as i32;
if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height {
let offset = ((py as u32 * width + px as u32) * 4) as usize;
blend_pixel(&mut buffer[offset..offset + 4], color, coverage);
}
});
}
}
}
fn draw_text_vertical_ccw(
buffer: &mut [u8],
width: u32,
height: u32,
left: i32,
top: i32,
text: &str,
font_size: f32,
color: Rgba,
) {
if text.is_empty() {
return;
}
let Some((pixels, bmp_width, bmp_height)) = render_text_bitmap(text, font_size) else {
return;
};
for y in 0..bmp_height {
for x in 0..bmp_width {
let coverage = pixels[(y * bmp_width + x) as usize];
if coverage == 0.0 {
continue;
}
let nx = left + y as i32;
let ny = top + (bmp_width - 1 - x) as i32;
if nx >= 0 && ny >= 0 && (nx as u32) < width && (ny as u32) < height {
let offset = ((ny as u32 * width + nx as u32) * 4) as usize;
blend_pixel(&mut buffer[offset..offset + 4], color, coverage);
}
}
}
}
type TickValues = Vec<(f32, String)>;
fn build_ticks(axis: &AxisConfig) -> TickValues {
let mut ticks = Vec::new();
let tick_count = axis.tick_count.max(MIN_TICK_COUNT);
let span = axis.max - axis.min;
let step = if tick_count <= 1 || span.abs() < f32::EPSILON {
0.0
} else {
span / (tick_count as f32 - 1.0)
};
for i in 0..tick_count {
let value = if step == 0.0 {
axis.min
} else {
axis.min + step * i as f32
};
let label = if let Some(units) = &axis.units {
format!("{value:.prec$} {}", units, prec = axis.decimal_places)
} else {
format!("{value:.prec$}", prec = axis.decimal_places)
};
ticks.push((value, label));
}
ticks
}
fn prepare_x_axis(axis: &AxisConfig) -> XAxisLayout {
let ticks = build_ticks(axis);
let mut tick_layouts = Vec::with_capacity(ticks.len());
let mut label: Option<AxisLabelLayout> = None;
for (value, label_text) in ticks {
let ratio = if axis.max - axis.min == 0.0 {
0.0
} else {
(value - axis.min) / (axis.max - axis.min)
};
let metrics = measure_text(&label_text, axis.tick_label_font_size);
tick_layouts.push(AxisTickLayout {
ratio: ratio.clamp(0.0, 1.0),
label: label_text,
metrics,
});
}
if !axis.label.is_empty() {
let metrics = measure_text(&axis.label, axis.label_font_size);
label = Some(AxisLabelLayout {
text: axis.label.clone(),
metrics,
});
}
XAxisLayout {
ticks: tick_layouts,
label,
tick_length: axis.tick_length,
}
}
fn prepare_y_axis(axis: &AxisConfig) -> YAxisLayout {
let ticks = build_ticks(axis);
let mut tick_layouts = Vec::with_capacity(ticks.len());
let mut max_tick_label_width = 0;
for (value, label_text) in ticks {
let ratio = if axis.max - axis.min == 0.0 {
0.0
} else {
(value - axis.min) / (axis.max - axis.min)
};
let metrics = measure_text(&label_text, axis.tick_label_font_size);
max_tick_label_width = max(max_tick_label_width, metrics.width);
tick_layouts.push(AxisTickLayout {
ratio: ratio.clamp(0.0, 1.0),
label: label_text,
metrics,
});
}
let mut label: Option<AxisLabelLayout> = None;
let mut rotated_label_width = 0;
let mut rotated_label_height = 0;
if !axis.label.is_empty() {
let metrics = measure_text(&axis.label, axis.label_font_size);
if let Some((_pixels, bmp_width, bmp_height)) =
render_text_bitmap(&axis.label, axis.label_font_size)
{
rotated_label_width = bmp_height.max(1);
rotated_label_height = bmp_width.max(1);
}
label = Some(AxisLabelLayout {
text: axis.label.clone(),
metrics,
});
}
YAxisLayout {
ticks: tick_layouts,
label,
rotated_label_width,
rotated_label_height,
tick_length: axis.tick_length,
max_tick_label_width,
}
}
fn fill_background(buffer: &mut [u8], bg: Rgba) {
for chunk in buffer.chunks_exact_mut(4) {
chunk.copy_from_slice(&bg);
}
}
fn copy_data_into_buffer(
dest: &mut [u8],
dest_width: u32,
dest_height: u32,
data: &[u8],
data_width: u32,
data_height: u32,
offset_x: u32,
offset_y: u32,
) {
debug_assert_eq!(dest.len(), (dest_width * dest_height * 4) as usize);
debug_assert_eq!(data.len(), (data_width * data_height * 4) as usize);
for row in 0..data_height {
let src_start = (row * data_width * 4) as usize;
let src_end = src_start + (data_width * 4) as usize;
let dest_row = offset_y + row;
let dest_start = ((dest_row * dest_width + offset_x) * 4) as usize;
let dest_end = dest_start + (data_width * 4) as usize;
dest[dest_start..dest_end].copy_from_slice(&data[src_start..src_end]);
}
}
fn draw_vertical_line(buffer: &mut [u8], width: u32, x: i32, y0: i32, y1: i32, color: Rgba) {
if x < 0 || (x as u32) >= width {
return;
}
let height = buffer.len() / 4 / width as usize;
let start = min(y0, y1);
let end = max(y0, y1);
for y in start..=end {
if y < 0 {
continue;
}
if (y as usize) >= height {
break;
}
let idx = ((y as u32 * width + x as u32) * 4) as usize;
buffer[idx..idx + 4].copy_from_slice(&color);
}
}
fn draw_horizontal_line(buffer: &mut [u8], width: u32, y: i32, x0: i32, x1: i32, color: Rgba) {
let height = buffer.len() / 4 / width as usize;
if y < 0 || (y as usize) >= height {
return;
}
let start = min(x0, x1);
let end = max(x0, x1);
for x in start..=end {
if x < 0 {
continue;
}
if (x as u32) >= width {
break;
}
let idx = ((y as u32 * width + x as u32) * 4) as usize;
buffer[idx..idx + 4].copy_from_slice(&color);
}
}
fn layout_context(annotations: &ChartAnnotations) -> LayoutContext {
let bg = annotations.border_color.background_rgba();
let fg = annotations.border_color.foreground_rgba();
LayoutContext {
border_bg: bg,
border_fg: fg,
padding: annotations.padding.max(8),
}
}
pub fn compose_with_annotations(
data: &[u8],
data_width: u32,
data_height: u32,
annotations: &ChartAnnotations,
) -> ChartImage {
assert_eq!(data.len(), (data_width * data_height * 4) as usize);
let layout_ctx = layout_context(annotations);
let x_axis_layout = annotations.x_axis.as_ref().map(prepare_x_axis);
let y_axis_layout = annotations.y_axis.as_ref().map(prepare_y_axis);
let mut top_margin = layout_ctx.padding as i32;
let mut bottom_margin = layout_ctx.padding as i32;
let mut left_margin = layout_ctx.padding as i32;
let right_margin = layout_ctx.padding as i32;
let mut title_metrics = None;
if let Some(title) = &annotations.title {
let metrics = measure_text(&title.text, title.font_size);
top_margin += metrics.height as i32 + TITLE_GAP as i32;
title_metrics = Some(metrics);
}
if let Some(axis) = x_axis_layout.as_ref() {
let tick_label_height = axis
.ticks
.iter()
.map(|t| t.metrics.height)
.max()
.unwrap_or(0);
bottom_margin += axis.tick_length as i32;
if tick_label_height > 0 {
bottom_margin += tick_label_height as i32 + TICK_LABEL_GAP as i32;
}
if let Some(label) = axis.label.as_ref() {
bottom_margin += label.metrics.height as i32 + AXIS_LABEL_GAP as i32;
}
}
if let Some(axis) = y_axis_layout.as_ref() {
left_margin += axis.tick_length as i32;
if axis.max_tick_label_width > 0 {
left_margin += axis.max_tick_label_width as i32 + TICK_LABEL_GAP as i32;
}
if axis.rotated_label_width > 0 {
left_margin += axis.rotated_label_width as i32 + AXIS_LABEL_GAP as i32;
}
}
let final_width = data_width as i32 + left_margin + right_margin;
let final_height = data_height as i32 + top_margin + bottom_margin;
if final_width < 0 || final_height < 0 {
panic!("invalid dimensions for annotated chart");
}
let final_width = final_width as u32;
let final_height = final_height as u32;
let mut pixels = vec![0u8; (final_width * final_height * 4) as usize];
fill_background(&mut pixels, layout_ctx.border_bg);
let data_left = left_margin as u32;
let data_top = top_margin as u32;
copy_data_into_buffer(
&mut pixels,
final_width,
final_height,
data,
data_width,
data_height,
data_left,
data_top,
);
if let (Some(title), Some(metrics)) = (&annotations.title, title_metrics) {
let text_width = metrics.width;
let title_left = (final_width as i32 - text_width as i32) / 2;
let title_area = data_top as i32;
let title_top = ((title_area - metrics.height as i32) / 2)
.max(layout_ctx.padding as i32 / 4)
.max(0);
draw_text(
&mut pixels,
final_width,
final_height,
title_left,
title_top,
&title.text,
title.font_size,
layout_ctx.border_fg,
);
}
if let (Some(axis_cfg), Some(axis_layout)) = (&annotations.x_axis, x_axis_layout.as_ref()) {
let data_bottom = data_top + data_height;
let tick_start = data_bottom as i32;
let tick_end = tick_start + axis_layout.tick_length as i32;
let tick_label_top = tick_end + TICK_LABEL_GAP as i32;
let mut current_bottom = tick_label_top;
for tick in &axis_layout.ticks {
let x = data_left as f32 + tick.ratio * data_width as f32;
let x = x.round() as i32;
draw_vertical_line(
&mut pixels,
final_width,
x,
tick_start,
tick_end,
layout_ctx.border_fg,
);
if !tick.label.is_empty() {
let text_width = tick.metrics.width as i32;
let text_left = x - text_width / 2;
draw_text(
&mut pixels,
final_width,
final_height,
text_left,
tick_label_top,
&tick.label,
axis_cfg.tick_label_font_size,
layout_ctx.border_fg,
);
current_bottom = current_bottom
.max(tick_label_top + tick.metrics.height as i32 + AXIS_LABEL_GAP as i32);
}
}
if let Some(label) = axis_layout.label.as_ref() {
let label_left = (final_width as i32 - label.metrics.width as i32) / 2;
draw_text(
&mut pixels,
final_width,
final_height,
label_left,
current_bottom,
&label.text,
axis_cfg.label_font_size,
layout_ctx.border_fg,
);
}
}
if let (Some(axis_cfg), Some(axis_layout)) = (&annotations.y_axis, y_axis_layout.as_ref()) {
let data_left_i32 = data_left as i32;
let data_top_i32 = data_top as i32;
let data_height_f = data_height as f32;
let tick_end = data_left_i32 - axis_layout.tick_length as i32;
let tick_label_right = tick_end - TICK_LABEL_GAP as i32;
for tick in &axis_layout.ticks {
let y_ratio = tick.ratio;
let y = data_top_i32 + ((1.0 - y_ratio) * data_height_f).round() as i32;
let tick_line_end = data_left_i32 - 1;
if tick_line_end >= 0 {
draw_horizontal_line(
&mut pixels,
final_width,
y,
tick_end,
tick_line_end,
layout_ctx.border_fg,
);
}
if !tick.label.is_empty() {
let text_width = tick.metrics.width as i32;
let text_height = tick.metrics.height as i32;
let text_left = tick_label_right - text_width;
let text_top = y - text_height / 2;
draw_text(
&mut pixels,
final_width,
final_height,
text_left,
text_top,
&tick.label,
axis_cfg.tick_label_font_size,
layout_ctx.border_fg,
);
}
}
if let Some(label) = axis_layout.label.as_ref() {
let data_center = data_top_i32 + data_height as i32 / 2;
let label_left = layout_ctx.padding as i32;
let label_top = data_center - axis_layout.rotated_label_height as i32 / 2;
draw_text_vertical_ccw(
&mut pixels,
final_width,
final_height,
label_left,
label_top,
&label.text,
axis_cfg.label_font_size,
layout_ctx.border_fg,
);
}
}
ChartImage {
pixels,
width: final_width,
height: final_height,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn annotations_expand_canvas_without_touching_data() {
let data_width = 6u32;
let data_height = 4u32;
let mut data = vec![0u8; (data_width * data_height * 4) as usize];
for y in 0..data_height {
for x in 0..data_width {
let idx = ((y * data_width + x) * 4) as usize;
data[idx] = x as u8;
data[idx + 1] = (y + 10) as u8; data[idx + 2] = 128;
data[idx + 3] = 255;
}
}
let mut annotations = ChartAnnotations::default();
annotations.title = Some(ChartTitle::new("Demo"));
annotations.x_axis = Some(AxisConfig::new("X", 0.0, 5.0).with_units("s"));
annotations.y_axis = Some(AxisConfig::new("Y", -2.0, 2.0).with_units("dB"));
let chart = compose_with_annotations(&data, data_width, data_height, &annotations);
assert!(chart.width > data_width);
assert!(chart.height > data_height);
let chart_width = chart.width as usize;
let chart_height = chart.height as usize;
let data_width_usize = data_width as usize;
let data_height_usize = data_height as usize;
let mut matches = Vec::new();
for y in 0..=chart_height - data_height_usize {
for x in 0..=chart_width - data_width_usize {
let mut identical = true;
'outer: for dy in 0..data_height_usize {
for dx in 0..data_width_usize {
let src_idx = ((dy * data_width_usize + dx) * 4) as usize;
let dst_idx = (((y + dy) * chart_width + (x + dx)) * 4) as usize;
if chart.pixels[dst_idx..dst_idx + 4] != data[src_idx..src_idx + 4] {
identical = false;
break 'outer;
}
}
}
if identical {
matches.push((x, y));
}
}
}
assert_eq!(
matches.len(),
1,
"expected the data image to appear exactly once"
);
let (min_x, min_y) = matches[0];
for dy in 0..data_height_usize {
for dx in 0..data_width_usize {
let src_idx = ((dy * data_width_usize + dx) * 4) as usize;
let dst_idx = (((min_y + dy) * chart_width + (min_x + dx)) * 4) as usize;
assert_eq!(
&chart.pixels[dst_idx..dst_idx + 4],
&data[src_idx..src_idx + 4]
);
}
}
assert_eq!(chart.pixels[0], 255);
assert_eq!(chart.pixels[1], 255);
assert_eq!(chart.pixels[2], 255);
assert_eq!(chart.pixels[3], 255);
}
}