use ab_glyph::{FontRef, PxScale};
use image::{Rgba, RgbaImage};
use imageproc::drawing::{draw_antialiased_line_segment_mut, draw_text_mut};
use crate::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlucoseStatus {
Low,
InRange,
High,
}
#[derive(Debug, Clone)]
pub struct SparklinePoint {
pub t: f32,
pub sgv: f32,
pub status: GlucoseStatus,
}
#[derive(Debug)]
pub struct BgCardData {
pub current_sgv: f32,
pub status: GlucoseStatus,
pub trend_arrow: String,
pub delta_str: String,
pub watermark_str: String,
pub age_str: String,
pub unit_str: String,
pub time_str: String,
pub iob_str: Option<String>,
pub cob_str: Option<String>,
pub sparkline_points: Vec<SparklinePoint>,
}
pub struct BgCardBuilder<'a> {
data: Option<BgCardData>,
theme: Theme,
font: &'a [u8],
scale: f32,
}
impl<'a> Default for BgCardBuilder<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> BgCardBuilder<'a> {
pub fn new() -> Self {
const DEFAULT_FONT: &[u8] = include_bytes!("../../assets/fonts/GeistMono-Regular.ttf");
Self {
data: None,
theme: Theme::dark(),
font: DEFAULT_FONT,
scale: 1.0,
}
}
pub fn with_data(mut self, data: BgCardData) -> Self {
self.data = Some(data);
self
}
pub fn with_theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn with_font(mut self, font: &'a [u8]) -> Self {
self.font = font;
self
}
pub fn with_scale(mut self, scale: f32) -> Self {
self.scale = scale;
self
}
pub fn build(self) -> Result<RgbaImage, Box<dyn std::error::Error>> {
let data = self.data.ok_or("BgCardData is required - call with_data() first")?;
let font = FontRef::try_from_slice(self.font)?;
let s = self.scale;
let w = (640.0 * s) as u32;
let h = (320.0 * s) as u32;
let mut img = RgbaImage::from_pixel(w, h, self.theme.background);
draw_bg_pattern(&mut img, &self.theme, w, h, s);
draw_gradient(&mut img, &self.theme, data.status, w, h);
draw_header(&mut img, &self.theme, &font, &data, w, s);
draw_content(&mut img, &self.theme, &font, &data, w, s);
draw_sparkline(&mut img, &self.theme, &data.sparkline_points, w, s);
Ok(img)
}
}
fn status_color(theme: &Theme, status: GlucoseStatus) -> Rgba<u8> {
match status {
GlucoseStatus::Low => theme.glucose_low,
GlucoseStatus::InRange => theme.glucose_in_range,
GlucoseStatus::High => theme.glucose_high,
}
}
fn approx_text_w(text: &str, size: f32) -> f32 {
text.chars().count() as f32 * size * 0.6
}
fn draw_bg_pattern(img: &mut RgbaImage, theme: &Theme, w: u32, h: u32, s: f32) {
let spacing = (64.0 * s) as u32;
let [br, bg, bb, ba] = theme.background.0;
let line = Rgba([
br.saturating_sub(7),
bg.saturating_sub(7),
bb.saturating_sub(7),
ba,
]);
let mut x = spacing;
while x < w {
for y in 0..h {
img.put_pixel(x, y, line);
}
x += spacing;
}
let mut y = spacing;
while y < h {
for x in 0..w {
img.put_pixel(x, y, line);
}
y += spacing;
}
}
fn draw_gradient(img: &mut RgbaImage, theme: &Theme, status: GlucoseStatus, w: u32, h: u32) {
let c = status_color(theme, status);
let gh = (h as f32 * 0.5) as u32;
for y in 0..gh {
let a = 55.0_f32 * (1.0 - y as f32 / gh as f32) / 255.0;
let inv = 1.0 - a;
for x in 0..w {
let px = img.get_pixel_mut(x, y);
let [dr, dg, db, da] = px.0;
px.0 = [
(c[0] as f32 * a + dr as f32 * inv) as u8,
(c[1] as f32 * a + dg as f32 * inv) as u8,
(c[2] as f32 * a + db as f32 * inv) as u8,
da,
];
}
}
}
fn draw_header(img: &mut RgbaImage, theme: &Theme, font: &FontRef, data: &BgCardData, w: u32, s: f32) {
let cy = 23.0 * s;
let pad = 24.0 * s;
let font_h = 22.0 * s;
let label_y = (cy - font_h / 2.0) as i32;
draw_text_mut(img, theme.text_primary, pad as i32, label_y,
PxScale::from(font_h), font, &data.watermark_str);
let tw = approx_text_w(&data.time_str, font_h);
let tx = (w as f32 - pad - tw) as i32;
let ty = (cy - font_h / 2.0) as i32;
draw_text_mut(img, theme.text_secondary, tx, ty,
PxScale::from(font_h), font, &data.time_str);
}
fn draw_content(img: &mut RgbaImage, theme: &Theme, font: &FontRef, data: &BgCardData, w: u32, s: f32) {
let pad = 24.0 * s;
let font_age = 16.0 * s;
let font_sgv = 76.0 * s;
let font_unit = 16.0 * s;
let font_delta = 21.0 * s;
let font_iob_cob = 30.0 * s;
let age_y = 62.0 * s;
let sgv_y = age_y + font_age + 4.0 * s;
let unit_y = sgv_y + font_sgv + 3.0 * s;
let delta_y = unit_y + font_unit + 4.0 * s;
let iob_y = sgv_y;
let cob_y = iob_y + font_iob_cob + 6.0 * s;
draw_text_mut(img, theme.text_secondary, pad as i32, age_y as i32,
PxScale::from(font_age), font, &data.age_str);
let value_str = format!("{:.0} {}", data.current_sgv, data.trend_arrow);
draw_text_mut(img, status_color(theme, data.status), pad as i32, sgv_y as i32,
PxScale::from(font_sgv), font, &value_str);
draw_text_mut(img, theme.text_dim, pad as i32, unit_y as i32,
PxScale::from(font_unit), font, &data.unit_str);
draw_text_mut(img, theme.text_primary, pad as i32, delta_y as i32,
PxScale::from(font_delta), font, &data.delta_str);
let x_right = w as f32 - pad;
let gap = approx_text_w(" ", font_iob_cob);
let iob_parts = data.iob_str.as_deref().and_then(|s| s.split_once(' '));
let cob_parts = data.cob_str.as_deref().and_then(|s| s.split_once(' '));
let max_val_w = [iob_parts, cob_parts]
.iter()
.flatten()
.map(|(_, v)| approx_text_w(v, font_iob_cob))
.fold(0.0_f32, f32::max);
let val_x = (x_right - max_val_w) as i32;
let label_x = (x_right - max_val_w - gap - approx_text_w("IOB", font_iob_cob)) as i32;
if let Some((label, value)) = iob_parts {
draw_text_mut(img, theme.text_secondary, label_x, iob_y as i32,
PxScale::from(font_iob_cob), font, label);
draw_text_mut(img, theme.text_secondary, val_x, iob_y as i32,
PxScale::from(font_iob_cob), font, value);
}
if let Some((label, value)) = cob_parts {
draw_text_mut(img, theme.text_secondary, label_x, cob_y as i32,
PxScale::from(font_iob_cob), font, label);
draw_text_mut(img, theme.text_secondary, val_x, cob_y as i32,
PxScale::from(font_iob_cob), font, value);
}
}
fn draw_sparkline(img: &mut RgbaImage, theme: &Theme, points: &[SparklinePoint], w: u32, s: f32) {
let zone_top = 224.0 * s;
let zone_h = 66.0 * s;
let thickness = (3.0 * s).round() as i32;
let grad_h = 40.0 * s;
let plot_w = w as f32;
let bottom_y = zone_top + zone_h;
if points.len() < 2 {
return;
}
let sgv_min = points.iter().map(|p| p.sgv).fold(f32::MAX, f32::min);
let sgv_max = points.iter().map(|p| p.sgv).fold(f32::MIN, f32::max);
let sgv_range = (sgv_max - sgv_min).max(20.0);
let map_x = |t: f32| t * plot_w;
let map_y = |sgv: f32| bottom_y - ((sgv - sgv_min) / sgv_range) * zone_h;
const GRAD_ALPHA: f32 = 55.0;
let img_w = img.width();
let img_h = img.height();
for i in 0..points.len() - 1 {
let p0 = &points[i];
let p1 = &points[i + 1];
let x0 = map_x(p0.t);
let x1 = map_x(p1.t);
let color = status_color(theme, p0.status);
let col_start = (x0 as i32).max(0) as u32;
let col_end = if i + 1 < points.len() - 1 {
(x1 as i32).max(0) as u32
} else {
(x1.ceil() as i32).min(img_w as i32) as u32
};
for col in col_start..col_end {
let frac = if x1 > x0 { (col as f32 - x0) / (x1 - x0) } else { 0.0 };
let curve_y = map_y(p0.sgv + frac * (p1.sgv - p0.sgv));
let fill_top = (curve_y as i32).max(0) as u32;
let fill_bot = ((curve_y + grad_h) as i32).min(img_h as i32) as u32;
for row in fill_top..fill_bot {
let a = GRAD_ALPHA * (1.0 - (row as f32 - curve_y) / grad_h) / 255.0;
let inv = 1.0 - a;
let px = img.get_pixel_mut(col, row);
let [dr, dg, db, da] = px.0;
px.0 = [
(color[0] as f32 * a + dr as f32 * inv) as u8,
(color[1] as f32 * a + dg as f32 * inv) as u8,
(color[2] as f32 * a + db as f32 * inv) as u8,
da,
];
}
}
}
for i in 0..points.len() - 1 {
let p0 = &points[i];
let p1 = &points[i + 1];
let x0 = map_x(p0.t);
let y0 = map_y(p0.sgv);
let x1 = map_x(p1.t);
let y1 = map_y(p1.sgv);
let color = status_color(theme, p0.status);
for t in -(thickness / 2)..=(thickness / 2) {
let tf = t as f32;
draw_antialiased_line_segment_mut(
img,
(x0 as i32, (y0 + tf) as i32),
(x1 as i32, (y1 + tf) as i32),
color,
|bg: Rgba<u8>, _, alpha| {
let inv = 1.0 - alpha;
Rgba([
(color[0] as f32 * alpha + bg[0] as f32 * inv) as u8,
(color[1] as f32 * alpha + bg[1] as f32 * inv) as u8,
(color[2] as f32 * alpha + bg[2] as f32 * inv) as u8,
bg[3],
])
},
);
}
}
}