Add A2UI heatmap charts

This commit is contained in:
Georg Bauer
2026-07-28 18:44:45 +02:00
parent abac978f54
commit 5ec873074b
5 changed files with 209 additions and 6 deletions

View File

@@ -1462,6 +1462,9 @@ fn research_chart<'a>(
if matches!(chart_type, "pie" | "donut") {
return research_pie_chart(component, series, chart_type == "donut", surface_height);
}
if chart_type == "heatmap" {
return research_heatmap(component, series);
}
let max = series
.iter()
.filter_map(|point| point.get("value").and_then(Value::as_f64))
@@ -1507,6 +1510,165 @@ fn research_chart<'a>(
rows.into()
}
#[derive(Debug, PartialEq)]
struct HeatmapRow {
label: String,
cells: Vec<f64>,
}
#[derive(Debug, PartialEq)]
struct HeatmapData {
columns: Vec<String>,
rows: Vec<HeatmapRow>,
max: f64,
}
fn heatmap_data(series: &[Value]) -> HeatmapData {
let rows = series
.iter()
.filter(|entry| {
entry
.get("segments")
.and_then(Value::as_array)
.is_some_and(|segments| !segments.is_empty())
})
.collect::<Vec<_>>();
let mut columns = Vec::new();
let mut max = 0.0_f64;
for entry in &rows {
for segment in entry["segments"].as_array().into_iter().flatten() {
if let Some(label) = segment.get("label").and_then(Value::as_str)
&& !columns.iter().any(|column| column == label)
{
columns.push(label.to_owned());
}
if let Some(value) = segment.get("value").and_then(Value::as_f64) {
max = max.max(value);
}
}
}
let rows = rows
.into_iter()
.map(|entry| {
let mut values = HashMap::new();
for segment in entry["segments"].as_array().into_iter().flatten() {
if let (Some(label), Some(value)) = (
segment.get("label").and_then(Value::as_str),
segment.get("value").and_then(Value::as_f64),
) {
values.insert(label, value);
}
}
HeatmapRow {
label: entry.get("label").map(display_value).unwrap_or_default(),
cells: columns
.iter()
.map(|column| values.get(column.as_str()).copied().unwrap_or(0.0))
.collect(),
}
})
.collect();
HeatmapData { columns, rows, max }
}
fn research_heatmap<'a>(
component: &serde_json::Map<String, Value>,
series: &[Value],
) -> Element<'a, Message> {
let heatmap = heatmap_data(series);
let mut chart = Column::new().spacing(8).width(Length::Fill);
if let Some(title) = title(component) {
chart = chart.push(title);
}
chart = chart.push(text("HEATMAP").size(10).color(muted_text()));
if heatmap.rows.is_empty() || heatmap.columns.is_empty() {
return chart.into();
}
chart
.push(responsive(move |size| {
let row_label_width = size.width.min(80.0);
let gap = 2.0;
let cell_side = ((size.width - row_label_width - gap * heatmap.columns.len() as f32)
/ heatmap.columns.len() as f32)
.max(14.0);
let mut grid = Column::new().spacing(gap);
let mut header = Row::new()
.spacing(gap)
.push(Space::new().width(row_label_width).height(14));
for label in &heatmap.columns {
header = header.push(
container(text(label.clone()).size(11).color(muted_text()))
.width(cell_side)
.align_x(Alignment::Center),
);
}
grid = grid.push(header);
for row in &heatmap.rows {
let mut cells = Row::new().spacing(gap).align_y(Alignment::Center).push(
container(text(row.label.clone()).size(11).color(muted_text()))
.width(row_label_width)
.padding([0, 4])
.align_x(Alignment::End),
);
for value in &row.cells {
let value = *value;
let (background, foreground) = heatmap_cell_colors(value, heatmap.max);
cells = cells.push(
container(
text(if value > 0.0 {
format_chart_value(value)
} else {
String::new()
})
.size(10),
)
.width(cell_side)
.height(cell_side)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(move |_| container::Style {
text_color: Some(foreground),
background: Some(background.into()),
border: Border {
radius: 2.0.into(),
..Border::default()
},
..container::Style::default()
}),
);
}
grid = grid.push(cells);
}
grid.into()
}))
.into()
}
fn heatmap_cell_colors(value: f64, max: f64) -> (Color, Color) {
let intensity = if max > 0.0 { value / max } else { 0.0 };
if intensity <= 0.0 {
return (Color::TRANSPARENT, app_theme().palette().text);
}
let intensity = intensity as f32;
let red = (53.0 + (183.0 - 53.0) * intensity).round();
let green = (117.0 + (72.0 - 117.0) * intensity).round();
let blue = (56.0 + (72.0 - 56.0) * intensity).round();
let opacity = 0.25 + intensity * 0.75;
let effective_red = red * opacity + 30.0 * (1.0 - opacity);
let effective_green = green * opacity + 30.0 * (1.0 - opacity);
let effective_blue = blue * opacity + 30.0 * (1.0 - opacity);
let foreground =
if 0.299 * effective_red + 0.587 * effective_green + 0.114 * effective_blue > 140.0 {
Color::BLACK
} else {
Color::WHITE
};
(
Color::from_rgba8(red as u8, green as u8, blue as u8, opacity),
foreground,
)
}
fn research_pie_chart<'a>(
component: &serde_json::Map<String, Value>,
series: &[Value],
@@ -2023,5 +2185,32 @@ mod tests {
assert!(donut.contains(">41</text>"));
assert_eq!(pie_chart_layout(1_000.0, 400.0), (320.0, 320.0, 16));
assert_eq!(pie_chart_layout(1_000.0, 700.0), (620.0, 560.0, 31));
let series = json!([
{"label":"2024","value":99,"segments":[{"label":"Jan","value":2},{"label":"Feb","value":4}]},
{"label":"plain","value":50},
{"label":"2025","segments":[{"label":"Feb","value":8},{"label":"Mar","value":1}]}
]);
let heatmap = heatmap_data(series.as_array().unwrap());
assert_eq!(heatmap.columns, ["Jan", "Feb", "Mar"]);
assert_eq!(heatmap.max, 8.0);
assert_eq!(
heatmap.rows,
[
HeatmapRow {
label: "2024".into(),
cells: vec![2.0, 4.0, 0.0],
},
HeatmapRow {
label: "2025".into(),
cells: vec![0.0, 8.0, 1.0],
},
]
);
assert_eq!(heatmap_cell_colors(0.0, heatmap.max).0, Color::TRANSPARENT);
assert_eq!(
heatmap_cell_colors(heatmap.max, heatmap.max),
(Color::from_rgb8(183, 72, 72), Color::WHITE)
);
}
}