A2UI parity: restore chart-type guidance in system prompt, add detailed schemas for form fields, card actions, tab content, and mindmap nodes
This commit is contained in:
@@ -684,19 +684,130 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
|
||||
<div class="chat-surface-chart-list">
|
||||
<%= for series <- @surface.series do %>
|
||||
<div class="chat-surface-chart-row">
|
||||
<div class="chat-surface-chart-meta">
|
||||
<span><%= series.label %></span>
|
||||
<span><%= series.value %></span>
|
||||
</div>
|
||||
<div class="chat-surface-chart-bar">
|
||||
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
|
||||
<%= case @surface.chart_type do %>
|
||||
<% chart_type when chart_type in ["pie", "donut"] -> %>
|
||||
<% pie = BDS.Desktop.ShellLive.ChatEditor.ChartView.pie(@surface.series) %>
|
||||
<div class="chat-surface-chart-pie">
|
||||
<svg class="chat-surface-chart-pie-svg" viewBox={"0 0 #{pie.size} #{pie.size}"} preserveAspectRatio="xMidYMid meet">
|
||||
<%= for slice <- pie.slices do %>
|
||||
<%= if slice.full_circle do %>
|
||||
<circle class="chat-surface-chart-pie-slice" cx={pie.center} cy={pie.center} r={pie.radius} fill={slice.color}>
|
||||
<title><%= slice.label %>: <%= slice.value %></title>
|
||||
</circle>
|
||||
<% else %>
|
||||
<path class="chat-surface-chart-pie-slice" d={slice.d} fill={slice.color}>
|
||||
<title><%= slice.label %>: <%= slice.value %></title>
|
||||
</path>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= if @surface.chart_type == "donut" do %>
|
||||
<circle class="chat-surface-chart-donut-hole" cx={pie.center} cy={pie.center} r={pie.donut_inner} />
|
||||
<text class="chat-surface-chart-donut-total" x={pie.center} y={pie.center} text-anchor="middle" dominant-baseline="central"><%= pie.total %></text>
|
||||
<% end %>
|
||||
</svg>
|
||||
<div class="chat-surface-chart-legend">
|
||||
<%= for item <- pie.legend do %>
|
||||
<span class="chat-surface-chart-legend-item">
|
||||
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||
<%= item.label %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% chart_type when chart_type in ["line", "area"] -> %>
|
||||
<% line = BDS.Desktop.ShellLive.ChatEditor.ChartView.line(@surface.series, @surface.chart_type == "area") %>
|
||||
<svg class="chat-surface-chart-line-svg" viewBox={line.view_box} preserveAspectRatio="xMidYMid meet">
|
||||
<%= for tick <- line.grid do %>
|
||||
<line class="chat-surface-chart-line-grid" x1={tick.x1} y1={tick.y} x2={tick.x2} y2={tick.y} />
|
||||
<text class="chat-surface-chart-line-y-label" x={tick.label_x} y={tick.y} text-anchor="end" dominant-baseline="middle"><%= tick.label %></text>
|
||||
<% end %>
|
||||
<%= if line.area? do %>
|
||||
<polygon class="chat-surface-chart-area-fill" points={line.area_points} />
|
||||
<% end %>
|
||||
<polyline class="chat-surface-chart-line-path" points={line.polyline} fill="none" />
|
||||
<%= for dot <- line.dots do %>
|
||||
<circle class="chat-surface-chart-line-dot" cx={dot.x} cy={dot.y} r="3">
|
||||
<title><%= dot.label %>: <%= dot.value %></title>
|
||||
</circle>
|
||||
<% end %>
|
||||
<%= for label <- line.x_labels do %>
|
||||
<text class="chat-surface-chart-line-x-label" x={label.x} y={label.y} text-anchor="middle"><%= label.label %></text>
|
||||
<% end %>
|
||||
</svg>
|
||||
|
||||
<% "heatmap" -> %>
|
||||
<% heat = BDS.Desktop.ShellLive.ChatEditor.ChartView.heatmap(@surface.series) %>
|
||||
<%= if heat.rows == [] do %>
|
||||
<div class="chat-surface-chart-list">
|
||||
<%= for series <- @surface.series do %>
|
||||
<div class="chat-surface-chart-row">
|
||||
<div class="chat-surface-chart-meta">
|
||||
<span><%= series.label %></span>
|
||||
<span><%= series.value %></span>
|
||||
</div>
|
||||
<div class="chat-surface-chart-bar">
|
||||
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="chat-surface-chart-heatmap" style={"grid-template-columns: auto repeat(#{heat.column_count}, 1fr)"}>
|
||||
<span class="chat-surface-chart-heatmap-corner"></span>
|
||||
<%= for col <- heat.columns do %>
|
||||
<span class="chat-surface-chart-heatmap-col-label"><%= col %></span>
|
||||
<% end %>
|
||||
<%= for row <- heat.rows do %>
|
||||
<span class="chat-surface-chart-heatmap-row-label"><%= row.label %></span>
|
||||
<%= for cell <- row.cells do %>
|
||||
<span class="chat-surface-chart-heatmap-cell" style={"background: #{cell.bg}; color: #{cell.fg}"} title={cell.value}><%= if cell.value > 0, do: cell.value %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% "stacked-bar" -> %>
|
||||
<% stacked = BDS.Desktop.ShellLive.ChatEditor.ChartView.stacked(@surface.series) %>
|
||||
<div class="chat-surface-chart-list">
|
||||
<%= for row <- stacked.rows do %>
|
||||
<div class="chat-surface-chart-row">
|
||||
<div class="chat-surface-chart-meta">
|
||||
<span><%= row.label %></span>
|
||||
<span><%= row.total %></span>
|
||||
</div>
|
||||
<div class="chat-surface-chart-bar chat-surface-chart-bar-stacked">
|
||||
<%= for seg <- row.segments do %>
|
||||
<span class="chat-surface-chart-bar-segment" style={"width: #{seg.width}%; background-color: #{seg.color}"} title={"#{seg.label}: #{seg.value}"}></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="chat-surface-chart-legend">
|
||||
<%= for item <- stacked.legend do %>
|
||||
<span class="chat-surface-chart-legend-item">
|
||||
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||
<%= item.label %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% _bar -> %>
|
||||
<div class="chat-surface-chart-list">
|
||||
<%= for series <- @surface.series do %>
|
||||
<div class="chat-surface-chart-row">
|
||||
<div class="chat-surface-chart-meta">
|
||||
<span><%= series.label %></span>
|
||||
<span><%= series.value %></span>
|
||||
</div>
|
||||
<div class="chat-surface-chart-bar">
|
||||
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% "metric" -> %>
|
||||
<div class="chat-surface-metric">
|
||||
|
||||
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
@@ -0,0 +1,284 @@
|
||||
defmodule BDS.Desktop.ShellLive.ChatEditor.ChartView do
|
||||
@moduledoc """
|
||||
Pure geometry/view helpers for rendering a2ui chart surfaces.
|
||||
|
||||
Mirrors the behaviour of the legacy bDS `A2UIChart` React component so the
|
||||
Elixir rewrite supports the same chart types (`bar`, `stacked-bar`, `line`,
|
||||
`area`, `pie`, `donut`, `heatmap`) with the same visual output. Each function
|
||||
returns plain maps/lists that a HEEx template can iterate over; no markup is
|
||||
produced here so the geometry stays unit-testable.
|
||||
|
||||
Series entries are expected to be maps with `:label`, `:value` and an optional
|
||||
list of `:segments` (each a map with `:label` and `:value`).
|
||||
"""
|
||||
|
||||
# Matches the legacy bDS SEGMENT_COLORS palette.
|
||||
@palette ["#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2e210"]
|
||||
|
||||
# Line/area chart geometry (viewBox units), matching the legacy component.
|
||||
@line_width 300
|
||||
@line_height 140
|
||||
@pad_top 8
|
||||
@pad_right 12
|
||||
@pad_bottom 24
|
||||
@pad_left 40
|
||||
|
||||
@doc "Returns the colour for the series/segment at `index` (cycles the palette)."
|
||||
@spec color(integer()) :: String.t()
|
||||
def color(index), do: Enum.at(@palette, rem(index, length(@palette)))
|
||||
|
||||
@doc """
|
||||
Pie/donut geometry: SVG slices, a legend and the running total.
|
||||
"""
|
||||
@spec pie([map()]) :: map()
|
||||
def pie(series) do
|
||||
total = series |> Enum.map(& &1.value) |> Enum.sum()
|
||||
center = 70
|
||||
radius = 56
|
||||
|
||||
{slices, _angle} =
|
||||
if total > 0 do
|
||||
series
|
||||
|> Enum.with_index()
|
||||
|> Enum.map_reduce(0.0, fn {entry, i}, current ->
|
||||
slice_angle = entry.value / total * 360
|
||||
end_angle = current + slice_angle
|
||||
|
||||
slice =
|
||||
if slice_angle >= 359.99 do
|
||||
%{full_circle: true, d: nil, color: color(i), label: entry.label, value: entry.value}
|
||||
else
|
||||
%{
|
||||
full_circle: false,
|
||||
d: describe_slice(center, radius, current, end_angle),
|
||||
color: color(i),
|
||||
label: entry.label,
|
||||
value: entry.value
|
||||
}
|
||||
end
|
||||
|
||||
{slice, end_angle}
|
||||
end)
|
||||
else
|
||||
{[], 0.0}
|
||||
end
|
||||
|
||||
%{
|
||||
size: 140,
|
||||
center: center,
|
||||
radius: radius,
|
||||
donut_inner: radius * 0.58,
|
||||
total: total,
|
||||
slices: slices,
|
||||
legend: legend(series)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Line/area geometry: gridlines + Y labels, the polyline points, an optional
|
||||
area polygon, the data dots and the X-axis labels.
|
||||
"""
|
||||
@spec line([map()], boolean()) :: map()
|
||||
def line(series, area?) do
|
||||
plot_width = @line_width - @pad_left - @pad_right
|
||||
plot_height = @line_height - @pad_top - @pad_bottom
|
||||
max_value = Enum.max([0 | Enum.map(series, & &1.value)])
|
||||
ticks = y_ticks(max_value)
|
||||
y_max = List.last(ticks)
|
||||
len = length(series)
|
||||
x_step = if len > 1, do: plot_width / (len - 1), else: 0.0
|
||||
|
||||
points =
|
||||
series
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {entry, i} ->
|
||||
x = @pad_left + if(len > 1, do: i * x_step, else: plot_width / 2)
|
||||
y = @pad_top + if(y_max > 0, do: (1 - entry.value / y_max) * plot_height, else: plot_height)
|
||||
%{x: x, y: y, label: entry.label, value: entry.value}
|
||||
end)
|
||||
|
||||
polyline = Enum.map_join(points, " ", fn p -> "#{fmt(p.x)},#{fmt(p.y)}" end)
|
||||
baseline = @pad_top + plot_height
|
||||
|
||||
area_points =
|
||||
case {area?, points} do
|
||||
{true, [_ | _]} ->
|
||||
first = List.first(points)
|
||||
last = List.last(points)
|
||||
"#{fmt(first.x)},#{fmt(baseline)} #{polyline} #{fmt(last.x)},#{fmt(baseline)}"
|
||||
|
||||
_ ->
|
||||
""
|
||||
end
|
||||
|
||||
grid =
|
||||
Enum.map(ticks, fn tick ->
|
||||
y = @pad_top + if(y_max > 0, do: (1 - tick / y_max) * plot_height, else: plot_height)
|
||||
|
||||
%{
|
||||
y: fmt(y),
|
||||
x1: fmt(@pad_left),
|
||||
x2: fmt(@pad_left + plot_width),
|
||||
label: fmt(tick),
|
||||
label_x: fmt(@pad_left - 4)
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
view_box: "0 0 #{@line_width} #{@line_height}",
|
||||
area?: area?,
|
||||
area_points: area_points,
|
||||
polyline: polyline,
|
||||
grid: grid,
|
||||
dots: Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(p.y), label: p.label, value: p.value} end),
|
||||
x_labels:
|
||||
Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(@line_height - 4), label: p.label} end)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Heatmap grid: column labels plus rows of colour-scaled cells. Each series
|
||||
entry is a row and each of its segments is a column.
|
||||
"""
|
||||
@spec heatmap([map()]) :: map()
|
||||
def heatmap(series) do
|
||||
rows_src = Enum.filter(series, &(&1.segments != []))
|
||||
columns = rows_src |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||
|
||||
global_max =
|
||||
case Enum.flat_map(rows_src, fn e -> Enum.map(e.segments, & &1.value) end) do
|
||||
[] -> 0
|
||||
values -> Enum.max(values)
|
||||
end
|
||||
|
||||
rows =
|
||||
Enum.map(rows_src, fn entry ->
|
||||
seg_map = Map.new(entry.segments, fn s -> {s.label, s.value} end)
|
||||
|
||||
cells =
|
||||
Enum.map(columns, fn col ->
|
||||
value = Map.get(seg_map, col, 0)
|
||||
alpha = if global_max > 0, do: value / global_max, else: 0.0
|
||||
{bg, fg} = cell_colors(alpha)
|
||||
%{value: value, bg: bg, fg: fg}
|
||||
end)
|
||||
|
||||
%{label: entry.label, cells: cells}
|
||||
end)
|
||||
|
||||
%{columns: columns, column_count: length(columns), rows: rows}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stacked-bar geometry: each row's segments with widths and colours, plus a
|
||||
legend of the unique segment labels.
|
||||
"""
|
||||
@spec stacked([map()]) :: map()
|
||||
def stacked(series) do
|
||||
seg_labels =
|
||||
series |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||
|
||||
totals =
|
||||
Enum.map(series, fn e ->
|
||||
if e.segments == [], do: e.value, else: Enum.sum(Enum.map(e.segments, & &1.value))
|
||||
end)
|
||||
|
||||
max_total = Enum.max([0 | totals])
|
||||
|
||||
rows =
|
||||
series
|
||||
|> Enum.zip(totals)
|
||||
|> Enum.map(fn {entry, total} ->
|
||||
segments =
|
||||
Enum.map(entry.segments, fn s ->
|
||||
width = if max_total > 0, do: s.value / max_total * 100, else: 0.0
|
||||
|
||||
%{
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
width: fmt(width),
|
||||
color: color(Enum.find_index(seg_labels, &(&1 == s.label)) || 0)
|
||||
}
|
||||
end)
|
||||
|
||||
%{label: entry.label, total: total, segments: segments}
|
||||
end)
|
||||
|
||||
legend = seg_labels |> Enum.with_index() |> Enum.map(fn {l, i} -> %{label: l, color: color(i)} end)
|
||||
|
||||
%{rows: rows, legend: legend, max_total: max_total}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Nice, rounded Y-axis tick values for `max_value`. Always starts at 0 and ends
|
||||
at or above `max_value`.
|
||||
"""
|
||||
@spec y_ticks(number(), pos_integer()) :: [number()]
|
||||
def y_ticks(max_value, tick_count \\ 4)
|
||||
def y_ticks(max_value, _tick_count) when max_value <= 0, do: [0]
|
||||
|
||||
def y_ticks(max_value, tick_count) do
|
||||
raw_step = max_value / (tick_count - 1)
|
||||
magnitude = :math.pow(10, Float.floor(:math.log10(raw_step)))
|
||||
normalised = raw_step / magnitude
|
||||
|
||||
nice_step =
|
||||
cond do
|
||||
normalised <= 1 -> magnitude
|
||||
normalised <= 2 -> 2 * magnitude
|
||||
normalised <= 5 -> 5 * magnitude
|
||||
true -> 10 * magnitude
|
||||
end
|
||||
|
||||
ticks =
|
||||
0.0
|
||||
|> Stream.iterate(&(&1 + nice_step))
|
||||
|> Enum.take_while(&(&1 <= max_value + nice_step * 0.01))
|
||||
|> Enum.map(&Float.round(&1, 6))
|
||||
|
||||
if length(ticks) < 2, do: ticks ++ [Float.round(nice_step, 6)], else: ticks
|
||||
end
|
||||
|
||||
# ── private helpers ──────────────────────────────────────────────────
|
||||
|
||||
defp legend(series) do
|
||||
series |> Enum.with_index() |> Enum.map(fn {e, i} -> %{label: e.label, color: color(i)} end)
|
||||
end
|
||||
|
||||
defp describe_slice(center, radius, start_angle, end_angle) do
|
||||
start_rad = (start_angle - 90) * :math.pi() / 180
|
||||
end_rad = (end_angle - 90) * :math.pi() / 180
|
||||
x1 = center + radius * :math.cos(start_rad)
|
||||
y1 = center + radius * :math.sin(start_rad)
|
||||
x2 = center + radius * :math.cos(end_rad)
|
||||
y2 = center + radius * :math.sin(end_rad)
|
||||
large_arc = if end_angle - start_angle > 180, do: 1, else: 0
|
||||
|
||||
"M#{fmt(center)},#{fmt(center)} L#{fmt(x1)},#{fmt(y1)} " <>
|
||||
"A#{fmt(radius)},#{fmt(radius)} 0 #{large_arc} 1 #{fmt(x2)},#{fmt(y2)} Z"
|
||||
end
|
||||
|
||||
defp cell_colors(alpha) when alpha <= 0, do: {"transparent", "inherit"}
|
||||
|
||||
defp cell_colors(alpha) do
|
||||
{r, g, b} = lerp_rgb({53, 117, 56}, {183, 72, 72}, alpha)
|
||||
opacity = 0.25 + alpha * 0.75
|
||||
bg = "rgba(#{r},#{g},#{b},#{Float.round(opacity, 2)})"
|
||||
er = round(r * opacity + 30 * (1 - opacity))
|
||||
eg = round(g * opacity + 30 * (1 - opacity))
|
||||
eb = round(b * opacity + 30 * (1 - opacity))
|
||||
fg = if 0.299 * er + 0.587 * eg + 0.114 * eb > 140, do: "#000", else: "#fff"
|
||||
{bg, fg}
|
||||
end
|
||||
|
||||
defp lerp_rgb({ar, ag, ab}, {br, bg, bb}, t) do
|
||||
{round(ar + (br - ar) * t), round(ag + (bg - ag) * t), round(ab + (bb - ab) * t)}
|
||||
end
|
||||
|
||||
defp fmt(n) when is_integer(n), do: Integer.to_string(n)
|
||||
|
||||
defp fmt(n) when is_float(n) do
|
||||
rounded = Float.round(n, 2)
|
||||
if rounded == Float.round(rounded, 0), do: Integer.to_string(trunc(rounded)), else: :erlang.float_to_binary(rounded, [:short])
|
||||
end
|
||||
end
|
||||
@@ -87,7 +87,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
||||
%{
|
||||
label: map_value(entry, "label", dgettext("ui", "Assistant")),
|
||||
value: numeric_value(map_value(entry, "value", 0)),
|
||||
segments: List.wrap(map_value(entry, "segments", []))
|
||||
segments:
|
||||
entry
|
||||
|> map_value("segments", [])
|
||||
|> List.wrap()
|
||||
|> Enum.map(fn segment ->
|
||||
%{
|
||||
label: map_value(segment, "label", ""),
|
||||
value: numeric_value(map_value(segment, "value", 0))
|
||||
}
|
||||
end)
|
||||
}
|
||||
end)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user