From 5ec873074be7310f286a483467a60b2b6bae7082 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Tue, 28 Jul 2026 18:44:45 +0200 Subject: [PATCH] Add A2UI heatmap charts --- README.md | 2 +- assets/a2ui/catalog.json | 2 +- src/a2ui.rs | 16 +++- src/a2ui_validation.rs | 6 ++ src/app/view/a2ui.rs | 189 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b3f09fa..5f92172 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ DS4Server renders A2UI v1.0 surfaces with these widgets: - **Layout:** rows, columns, lists, cards, modals, and tabs. - **Controls:** buttons, text fields, checkboxes, sliders, date/time inputs, and choice pickers. -- **Research:** bar, line, area, stacked-bar, pie, and donut charts; tables, +- **Research:** bar, line, area, stacked-bar, pie, donut, and heatmap charts; tables, metrics, timelines, maps, mind maps, and forms. Video posters render in the surface; video and audio playback uses native diff --git a/assets/a2ui/catalog.json b/assets/a2ui/catalog.json index 513fee7..2466bb1 100644 --- a/assets/a2ui/catalog.json +++ b/assets/a2ui/catalog.json @@ -71,7 +71,7 @@ "chart": { "allOf": [ { "$ref": "#/$defs/researchBase" }, - { "properties": { "component": { "const": "Chart" }, "chartType": { "enum": ["bar", "line", "area", "stackedBar", "pie", "donut"] }, "series": {} }, "required": ["chartType", "series"] } + { "properties": { "component": { "const": "Chart" }, "chartType": { "enum": ["bar", "line", "area", "stackedBar", "pie", "donut", "heatmap"] }, "series": {} }, "required": ["chartType", "series"] } ], "unevaluatedProperties": false }, diff --git a/src/a2ui.rs b/src/a2ui.rs index c3456bc..5adfa74 100644 --- a/src/a2ui.rs +++ b/src/a2ui.rs @@ -75,7 +75,7 @@ Every line must be one JSON object with `version":"v1.0"` and exactly one of `cr Catalog components: - Basic: Text(text Markdown,variant caption|body), Image(url,description,fit contain|cover|fill|none|scaleDown,variant icon|avatar|smallFeature|mediumFeature|largeFeature|header), Icon(name; safe examples: info|error|check|close|search|settings), Video(url,posterUrl), AudioPlayer(url,description), Divider(axis horizontal|vertical), Row/Column(children,justify start|center|end|spaceBetween|spaceAround|spaceEvenly|stretch,align start|center|end|stretch), List(children,direction vertical|horizontal,align start|center|end|stretch), Card(child), Modal(trigger,content), Tabs(tabs[{title,child}]), Button(child,variant default|primary|borderless,action:{event:{name,context,wantResponse}}), TextField(label,value:{path},variant shortText|longText|number|obscured,placeholder), CheckBox(label,value:{path}), Slider(value:{path},max,min,steps positive integer), DateTimeInput(label,value:{path},enableDate,enableTime,min,max), ChoicePicker(label,options[{label,value}],value:{path},variant multipleSelection|mutuallyExclusive,displayStyle checkbox|chips,filterable). Put numeric weight on direct Row/Column children to distribute available space. -- Research: Chart(title,chartType bar|line|area|stackedBar|pie|donut,series[{label,value,segments}]), Table(title,columns,rows), Metric(label,value,detail,trend), Timeline(title,events[{time,title,description,status}]), Map(title,locations[{label,latitude,longitude,detail}]), MindMap(title,nodes[{id,label,children}]), Form(title,children,submitLabel,action). Use pie or donut for proportional breakdowns; donut displays the total in its center. +- Research: Chart(title,chartType bar|line|area|stackedBar|pie|donut|heatmap,series[{label,value,segments}]), Table(title,columns,rows), Metric(label,value,detail,trend), Timeline(title,events[{time,title,description,status}]), Map(title,locations[{label,latitude,longitude,detail}]), MindMap(title,nodes[{id,label,children}]), Form(title,children,submitLabel,action). Use pie or donut for proportional breakdowns; donut displays the total in its center. Use heatmap for a matrix: every series entry is a row and its segments are the labeled columns whose numeric values determine color intensity. A heatmap without segments is empty. - Shared fields: id, accessibility, weight, checks. Checks use {"condition":{"call":"required","args":{...}},"message":"..."}. Bind dynamic values with {"path":"/json/pointer"}. The renderer supports every function in the v1.0 Basic Catalog, including validation, formatting, logic, pluralize, openUrl, and @index. Input edits are local and synchronous. Agent events receive their resolved context and current data model. Server-initiated `callFunction` messages are supported. Example: @@ -1082,7 +1082,15 @@ fn validate_component(component: &Map, catalog_id: &str) -> Resul "Chart" => validate_enum( component, "chartType", - &["bar", "line", "area", "stackedBar", "pie", "donut"], + &[ + "bar", + "line", + "area", + "stackedBar", + "pie", + "donut", + "heatmap", + ], id, )?, _ => {} @@ -2256,14 +2264,14 @@ mod tests { } #[test] - fn catalog_accepts_pie_and_donut_charts() { + fn catalog_accepts_pie_donut_and_heatmap_charts() { let mut store = Store::default(); apply( &mut store, json!({"version":VERSION,"createSurface":{"surfaceId":"s","catalogId":CATALOG_ID}}), ) .unwrap(); - for chart_type in ["pie", "donut"] { + for chart_type in ["pie", "donut", "heatmap"] { apply( &mut store, json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Chart","chartType":chart_type,"series":[{"label":"rs","value":41}]}]}}), diff --git a/src/a2ui_validation.rs b/src/a2ui_validation.rs index fc19cd9..7dffec0 100644 --- a/src/a2ui_validation.rs +++ b/src/a2ui_validation.rs @@ -23,6 +23,12 @@ const CASES: &[Case] = &[ components: &["Chart"], chart_type: Some("donut"), }, + Case { + name: "heatmap-natural", + prompt: "Do not call tools. Show a heatmap of pull requests reviewed in 2025 and 2026, with each year as a row and January, February, and March as columns. Use values 2, 5, 3 for 2025 and 4, 1, 6 for 2026.", + components: &["Chart"], + chart_type: Some("heatmap"), + }, Case { name: "form-controls", prompt: "Do not call tools. Build an A2UI Form for a name, multiline notes, due date, and multiple checkbox priorities, with a submit button.", diff --git a/src/app/view/a2ui.rs b/src/app/view/a2ui.rs index 471d2e8..dfc5e5b 100644 --- a/src/app/view/a2ui.rs +++ b/src/app/view/a2ui.rs @@ -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, +} + +#[derive(Debug, PartialEq)] +struct HeatmapData { + columns: Vec, + rows: Vec, + 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::>(); + 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, + 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, series: &[Value], @@ -2023,5 +2185,32 @@ mod tests { assert!(donut.contains(">41")); 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) + ); } }