Add A2UI heatmap charts
This commit is contained in:
@@ -48,7 +48,7 @@ DS4Server renders A2UI v1.0 surfaces with these widgets:
|
|||||||
- **Layout:** rows, columns, lists, cards, modals, and tabs.
|
- **Layout:** rows, columns, lists, cards, modals, and tabs.
|
||||||
- **Controls:** buttons, text fields, checkboxes, sliders, date/time inputs, and
|
- **Controls:** buttons, text fields, checkboxes, sliders, date/time inputs, and
|
||||||
choice pickers.
|
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.
|
metrics, timelines, maps, mind maps, and forms.
|
||||||
|
|
||||||
Video posters render in the surface; video and audio playback uses native
|
Video posters render in the surface; video and audio playback uses native
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
"chart": {
|
"chart": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{ "$ref": "#/$defs/researchBase" },
|
{ "$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
|
"unevaluatedProperties": false
|
||||||
},
|
},
|
||||||
|
|||||||
16
src/a2ui.rs
16
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:
|
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.
|
- 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.
|
- 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:
|
Example:
|
||||||
@@ -1082,7 +1082,15 @@ fn validate_component(component: &Map<String, Value>, catalog_id: &str) -> Resul
|
|||||||
"Chart" => validate_enum(
|
"Chart" => validate_enum(
|
||||||
component,
|
component,
|
||||||
"chartType",
|
"chartType",
|
||||||
&["bar", "line", "area", "stackedBar", "pie", "donut"],
|
&[
|
||||||
|
"bar",
|
||||||
|
"line",
|
||||||
|
"area",
|
||||||
|
"stackedBar",
|
||||||
|
"pie",
|
||||||
|
"donut",
|
||||||
|
"heatmap",
|
||||||
|
],
|
||||||
id,
|
id,
|
||||||
)?,
|
)?,
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -2256,14 +2264,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_accepts_pie_and_donut_charts() {
|
fn catalog_accepts_pie_donut_and_heatmap_charts() {
|
||||||
let mut store = Store::default();
|
let mut store = Store::default();
|
||||||
apply(
|
apply(
|
||||||
&mut store,
|
&mut store,
|
||||||
json!({"version":VERSION,"createSurface":{"surfaceId":"s","catalogId":CATALOG_ID}}),
|
json!({"version":VERSION,"createSurface":{"surfaceId":"s","catalogId":CATALOG_ID}}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for chart_type in ["pie", "donut"] {
|
for chart_type in ["pie", "donut", "heatmap"] {
|
||||||
apply(
|
apply(
|
||||||
&mut store,
|
&mut store,
|
||||||
json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Chart","chartType":chart_type,"series":[{"label":"rs","value":41}]}]}}),
|
json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Chart","chartType":chart_type,"series":[{"label":"rs","value":41}]}]}}),
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const CASES: &[Case] = &[
|
|||||||
components: &["Chart"],
|
components: &["Chart"],
|
||||||
chart_type: Some("donut"),
|
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 {
|
Case {
|
||||||
name: "form-controls",
|
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.",
|
prompt: "Do not call tools. Build an A2UI Form for a name, multiline notes, due date, and multiple checkbox priorities, with a submit button.",
|
||||||
|
|||||||
@@ -1462,6 +1462,9 @@ fn research_chart<'a>(
|
|||||||
if matches!(chart_type, "pie" | "donut") {
|
if matches!(chart_type, "pie" | "donut") {
|
||||||
return research_pie_chart(component, series, chart_type == "donut", surface_height);
|
return research_pie_chart(component, series, chart_type == "donut", surface_height);
|
||||||
}
|
}
|
||||||
|
if chart_type == "heatmap" {
|
||||||
|
return research_heatmap(component, series);
|
||||||
|
}
|
||||||
let max = series
|
let max = series
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|point| point.get("value").and_then(Value::as_f64))
|
.filter_map(|point| point.get("value").and_then(Value::as_f64))
|
||||||
@@ -1507,6 +1510,165 @@ fn research_chart<'a>(
|
|||||||
rows.into()
|
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>(
|
fn research_pie_chart<'a>(
|
||||||
component: &serde_json::Map<String, Value>,
|
component: &serde_json::Map<String, Value>,
|
||||||
series: &[Value],
|
series: &[Value],
|
||||||
@@ -2023,5 +2185,32 @@ mod tests {
|
|||||||
assert!(donut.contains(">41</text>"));
|
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, 400.0), (320.0, 320.0, 16));
|
||||||
assert_eq!(pie_chart_layout(1_000.0, 700.0), (620.0, 560.0, 31));
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user