use super::iwork::{first_bytes, first_varint, Archive, Fields, Value};
use super::pages::{Chart, ChartSeries};
use super::pages_iwa::{reference_field, Objects};
pub(crate) const TSCH_CHART_DRAWABLE: u32 = 5021;
const DRAWABLE_CHART_FIELD: u32 = 10000;
const CHART_TYPE_FIELD: u32 = 1;
const CHART_SERIES_DIRECTION_FIELD: u32 = 5;
const CHART_GRID_FIELD: u32 = 7;
const CHART_NON_STYLE_FIELD: u32 = 10;
const GRID_ROW_NAME_FIELD: u32 = 1;
const GRID_COLUMN_NAME_FIELD: u32 = 2;
const GRID_ROW_FIELD: u32 = 3;
const GRID_ROW_VALUE_FIELD: u32 = 1;
const GRID_VALUE_NUMBER_FIELD: u32 = 1;
const SERIES_BY_ROW: u64 = 1;
const TSCH_CHART_NON_STYLE: u32 = 5023;
const NON_STYLE_PROPERTIES_FIELD: u32 = 10000;
const NON_STYLE_SHOW_TITLE_FIELD: u32 = 21;
const NON_STYLE_TITLE_FIELD: u32 = 23;
const MAX_CHART_CELLS: usize = 100_000;
fn classification(chart_type: u64) -> &'static str {
match chart_type {
1 | 2 | 6 | 7 | 12 | 13 | 17 | 18 | 20 | 21 => "bar_chart",
3 | 14 => "line_chart",
5 | 16 | 25 | 26 => "pie_chart",
9 | 23 => "scatter_chart",
_ => "other_chart",
}
}
pub(crate) fn iwa_chart(drawable: &Archive, objects: &Objects) -> Option<Chart> {
let raw = first_bytes(&drawable.payload, DRAWABLE_CHART_FIELD)?;
let label = classification(first_varint(raw, CHART_TYPE_FIELD).unwrap_or(0));
let by_row = first_varint(raw, CHART_SERIES_DIRECTION_FIELD) == Some(SERIES_BY_ROW);
let (categories, series) = chart_grid(first_bytes(raw, CHART_GRID_FIELD), by_row);
Some(Chart {
label: label.to_string(),
title: chart_title(raw, objects),
categories,
series,
})
}
fn chart_grid(raw: Option<&[u8]>, by_row: bool) -> (Vec<String>, Vec<ChartSeries>) {
let Some(grid) = raw else {
return (Vec::new(), Vec::new());
};
let names = |field: u32| -> Vec<String> {
Fields::new(grid)
.filter_map(|(f, v)| match v {
Value::Bytes(b) if f == field => Some(text(b)),
_ => None,
})
.collect()
};
let row_names = names(GRID_ROW_NAME_FIELD);
let column_names = names(GRID_COLUMN_NAME_FIELD);
let mut rows: Vec<Vec<Option<f64>>> = Vec::new();
let mut width = 0;
for (f, v) in Fields::new(grid) {
let Value::Bytes(row) = v else {
continue;
};
if f != GRID_ROW_FIELD {
continue;
}
let values: Vec<Option<f64>> = Fields::new(row)
.filter_map(|(f, v)| match v {
Value::Bytes(b) if f == GRID_ROW_VALUE_FIELD => Some(grid_value(b)),
_ => None,
})
.collect();
width = width.max(values.len());
if (rows.len() + 1) * width > MAX_CHART_CELLS {
return (Vec::new(), Vec::new());
}
rows.push(values);
}
if by_row {
let categories = named(&column_names, width);
let series = named(&row_names, rows.len())
.into_iter()
.zip(rows)
.map(|(name, mut row)| {
row.resize(width, None);
ChartSeries { name, values: row }
})
.collect();
return (categories, series);
}
let categories = named(&row_names, rows.len());
let series = named(&column_names, width)
.into_iter()
.enumerate()
.map(|(column, name)| ChartSeries {
name,
values: rows
.iter()
.map(|row| row.get(column).copied().flatten())
.collect(),
})
.collect();
(categories, series)
}
fn chart_title(chart: &[u8], objects: &Objects) -> Option<String> {
let target = reference_field(chart, CHART_NON_STYLE_FIELD)?;
let non_style = objects.get(&target).copied()?;
if non_style.ty != TSCH_CHART_NON_STYLE {
return None;
}
let properties = first_bytes(&non_style.payload, NON_STYLE_PROPERTIES_FIELD)?;
if first_varint(properties, NON_STYLE_SHOW_TITLE_FIELD) != Some(1) {
return None;
}
Some(text(first_bytes(properties, NON_STYLE_TITLE_FIELD)?)).filter(|t| !t.is_empty())
}
fn grid_value(raw: &[u8]) -> Option<f64> {
Fields::new(raw)
.find_map(|(f, v)| match v {
Value::Fixed64(bits) if f == GRID_VALUE_NUMBER_FIELD => Some(f64::from_bits(bits)),
_ => None,
})
.filter(|v| v.is_finite())
}
fn named(names: &[String], count: usize) -> Vec<String> {
let mut out: Vec<String> = names.iter().take(count).cloned().collect();
out.resize(count, String::new());
out
}
fn text(raw: &[u8]) -> String {
String::from_utf8_lossy(raw).trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn varint(mut v: u64) -> Vec<u8> {
let mut out = Vec::new();
loop {
let low = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
out.push(low);
return out;
}
out.push(low | 0x80);
}
}
fn field_varint(n: u32, v: u64) -> Vec<u8> {
let mut out = varint(u64::from(n) << 3);
out.extend(varint(v));
out
}
fn field_bytes(n: u32, b: &[u8]) -> Vec<u8> {
let mut out = varint(u64::from(n) << 3 | 2);
out.extend(varint(b.len() as u64));
out.extend_from_slice(b);
out
}
fn field_double(n: u32, v: f64) -> Vec<u8> {
let mut out = varint(u64::from(n) << 3 | 1);
out.extend_from_slice(&v.to_bits().to_le_bytes());
out
}
fn grid(row_names: &[&str], column_names: &[&str], rows: &[&[Option<f64>]]) -> Vec<u8> {
let mut g = Vec::new();
for n in row_names {
g.extend(field_bytes(GRID_ROW_NAME_FIELD, n.as_bytes()));
}
for n in column_names {
g.extend(field_bytes(GRID_COLUMN_NAME_FIELD, n.as_bytes()));
}
for row in rows {
let mut r = Vec::new();
for v in row.iter() {
let value = match v {
Some(x) => field_double(GRID_VALUE_NUMBER_FIELD, *x),
None => Vec::new(),
};
r.extend(field_bytes(GRID_ROW_VALUE_FIELD, &value));
}
g.extend(field_bytes(GRID_ROW_FIELD, &r));
}
g
}
#[test]
fn a_pie_by_row_with_a_shown_title() {
let g = grid(
&["Home", "Food"],
&["Amount"],
&[&[Some(-872.4)], &[Some(-226.0)]],
);
let mut chart = field_varint(CHART_TYPE_FIELD, 5);
chart.extend(field_varint(CHART_SERIES_DIRECTION_FIELD, SERIES_BY_ROW));
chart.extend(field_bytes(CHART_GRID_FIELD, &g));
chart.extend(field_bytes(CHART_NON_STYLE_FIELD, &field_varint(1, 77)));
let drawable = Archive {
id: 1,
ty: TSCH_CHART_DRAWABLE,
payload: field_bytes(DRAWABLE_CHART_FIELD, &chart),
};
let mut props = field_varint(NON_STYLE_SHOW_TITLE_FIELD, 1);
props.extend(field_bytes(
NON_STYLE_TITLE_FIELD,
b"Expenditure by Category",
));
let non_style = Archive {
id: 77,
ty: TSCH_CHART_NON_STYLE,
payload: field_bytes(NON_STYLE_PROPERTIES_FIELD, &props),
};
let objects: Objects = HashMap::from([(1, &drawable), (77, &non_style)]);
let chart = iwa_chart(&drawable, &objects).expect("a chart");
assert_eq!(chart.label, "pie_chart");
assert_eq!(chart.title.as_deref(), Some("Expenditure by Category"));
assert_eq!(chart.categories, ["Amount"]);
let series: Vec<(&str, &[Option<f64>])> = chart
.series
.iter()
.map(|s| (s.name.as_str(), s.values.as_slice()))
.collect();
assert_eq!(
series,
[("Home", &[Some(-872.4)][..]), ("Food", &[Some(-226.0)][..])]
);
let table = chart.table().expect("data");
assert_eq!(
table.rows,
[["", "Home", "Food"], ["Amount", "-872.4", "-226"]]
);
}
#[test]
fn by_column_pads_and_names_the_unnamed() {
let g = grid(&["a"], &[], &[&[Some(1.0), Some(2.5)], &[None]]);
let mut chart = field_varint(CHART_TYPE_FIELD, 3);
chart.extend(field_bytes(CHART_GRID_FIELD, &g));
let drawable = Archive {
id: 1,
ty: TSCH_CHART_DRAWABLE,
payload: field_bytes(DRAWABLE_CHART_FIELD, &chart),
};
let objects: Objects = HashMap::from([(1, &drawable)]);
let chart = iwa_chart(&drawable, &objects).expect("a chart");
assert_eq!(chart.label, "line_chart");
assert_eq!(chart.title, None);
assert_eq!(chart.categories, ["a", ""]);
assert_eq!(chart.series.len(), 2);
assert_eq!(chart.series[0].values, [Some(1.0), None]);
assert_eq!(chart.series[1].values, [Some(2.5), None]);
let table = chart.table().expect("data");
assert_eq!(table.rows, [["", "", ""], ["a", "1", "2.5"], ["", "", ""]]);
}
}