diff --git a/assets/css/assistant.css b/assets/css/assistant.css index 413e0eb..be72c31 100644 --- a/assets/css/assistant.css +++ b/assets/css/assistant.css @@ -272,6 +272,129 @@ transition: width 0.3s ease; } +/* Stacked bars: segments sit side by side inside the track. */ +.chat-surface-chart-bar-stacked { + display: flex; +} + +.chat-surface-chart-bar-segment { + display: block; + height: 100%; + min-width: 0; + transition: width 0.3s ease; +} + +/* Shared legend (pie/donut/stacked-bar). */ +.chat-surface-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 8px; + font-size: 11px; +} + +.chat-surface-chart-legend-item { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--vscode-descriptionForeground); +} + +.chat-surface-chart-legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; +} + +/* Pie / donut. */ +.chat-surface-chart-pie { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.chat-surface-chart-pie-svg { + width: 140px; + height: 140px; +} + +.chat-surface-chart-pie-slice { + stroke: var(--vscode-editor-background, #1e1e1e); + stroke-width: 1; +} + +.chat-surface-chart-donut-hole { + fill: var(--vscode-editor-background, #1e1e1e); +} + +.chat-surface-chart-donut-total { + fill: var(--vscode-editor-foreground); + font-size: 16px; + font-weight: 600; +} + +/* Line / area. */ +.chat-surface-chart-line-svg { + width: 100%; + height: auto; +} + +.chat-surface-chart-line-grid { + stroke: rgba(255, 255, 255, 0.08); + stroke-width: 1; +} + +.chat-surface-chart-line-y-label, +.chat-surface-chart-line-x-label { + fill: var(--vscode-descriptionForeground); + font-size: 9px; +} + +.chat-surface-chart-line-path { + stroke: var(--accent-color); + stroke-width: 2; +} + +.chat-surface-chart-area-fill { + fill: var(--accent-color); + opacity: 0.18; +} + +.chat-surface-chart-line-dot { + fill: var(--accent-color); +} + +/* Heatmap. */ +.chat-surface-chart-heatmap { + display: grid; + gap: 2px; + font-size: 10px; +} + +.chat-surface-chart-heatmap-col-label, +.chat-surface-chart-heatmap-row-label { + display: flex; + align-items: center; + padding: 2px 4px; + color: var(--vscode-descriptionForeground); +} + +.chat-surface-chart-heatmap-col-label { + justify-content: center; +} + +.chat-surface-chart-heatmap-cell { + display: flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 4px 2px; + border-radius: 2px; + font-variant-numeric: tabular-nums; +} + /* ── Card surface ──────────────────────────────────────────────────── */ .chat-surface-card { diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index d809714..33c2b32 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -786,15 +786,16 @@ defmodule BDS.AI.Chat do "- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.", "If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.", "", - "Available UI Render Tools:", - "- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.", - "- Use render_table for tabular data, comparisons, and structured listings.", - "- Use render_form to collect structured user input.", - "- Use render_card for summaries, highlights, or actionable items.", - "- Use render_metric for a single KPI or important statistic.", - "- Use render_list for bullet lists, checklists, or simple enumerations.", - "- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.", - "When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data." + "Available UI Render Tools (use these to show rich interactive elements):", + "- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. IMPORTANT: a heatmap needs structured data — each entry in 'series' is a ROW and must include a 'segments' array whose entries are the COLUMNS (every segment needs a 'label' and a numeric 'value'); the row's own 'value' is ignored. Plan what the rows and columns represent before fetching data (e.g. rows = years, columns = months). A heatmap sent without segments renders empty.", + "- render_table: Show data in a structured table. Use for tabular comparisons and listings.", + "- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).", + "- render_card: Show an information card with title, body, and action buttons.", + "- render_metric: Show a single KPI or statistic prominently.", + "- render_list: Show a bulleted list of items.", + "- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.", + "", + "When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. When you need user input for a multi-field operation, use render_form to present a structured form. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media). When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab. When building any visualization, render it as soon as you have enough data." ], "\n" ) diff --git a/lib/bds/ai/chat_tools.ex b/lib/bds/ai/chat_tools.ex index 4819b45..a77c211 100644 --- a/lib/bds/ai/chat_tools.ex +++ b/lib/bds/ai/chat_tools.ex @@ -803,10 +803,40 @@ defmodule BDS.AI.ChatTools do %{ "type" => "object", "properties" => %{ - "title" => %{"type" => "string"}, - "fields" => %{"type" => "array"}, - "submitLabel" => %{"type" => "string"}, - "submitAction" => %{"type" => "string"} + "title" => %{"type" => "string", "description" => "Optional form title"}, + "fields" => %{ + "type" => "array", + "description" => "Form fields to display", + "items" => %{ + "type" => "object", + "properties" => %{ + "key" => %{"type" => "string", "description" => "Field identifier"}, + "label" => %{"type" => "string", "description" => "Field label shown to user"}, + "inputType" => %{ + "type" => "string", + "enum" => ["text", "textarea", "select", "checkbox", "date", "number"], + "description" => "Type of input control" + }, + "placeholder" => %{"type" => "string", "description" => "Placeholder text"}, + "defaultValue" => %{"type" => "string", "description" => "Default value"}, + "options" => %{ + "type" => "array", + "description" => "Options for select fields", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string"}, + "value" => %{"type" => "string"} + } + } + }, + "required" => %{"type" => "boolean", "description" => "Whether the field is required"} + }, + "required" => ["key", "label", "inputType"] + } + }, + "submitLabel" => %{"type" => "string", "description" => "Label for the submit button"}, + "submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"} } } end @@ -815,10 +845,25 @@ defmodule BDS.AI.ChatTools do %{ "type" => "object", "properties" => %{ - "title" => %{"type" => "string"}, - "subtitle" => %{"type" => "string"}, - "body" => %{"type" => "string"}, - "actions" => %{"type" => "array"} + "title" => %{"type" => "string", "description" => "Card title"}, + "subtitle" => %{"type" => "string", "description" => "Optional subtitle"}, + "body" => %{"type" => "string", "description" => "Card body text (supports markdown)"}, + "actions" => %{ + "type" => "array", + "description" => "Optional action buttons on the card", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string", "description" => "Button label"}, + "action" => %{"type" => "string", "description" => "Action name to dispatch"}, + "payload" => %{ + "type" => "object", + "description" => "Optional action payload" + } + }, + "required" => ["label", "action"] + } + } } } end @@ -827,8 +872,8 @@ defmodule BDS.AI.ChatTools do %{ "type" => "object", "properties" => %{ - "label" => %{"type" => "string"}, - "value" => %{"type" => "string"} + "label" => %{"type" => "string", "description" => "Metric label"}, + "value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"} } } end @@ -837,8 +882,12 @@ defmodule BDS.AI.ChatTools do %{ "type" => "object", "properties" => %{ - "title" => %{"type" => "string"}, - "items" => %{"type" => "array"} + "title" => %{"type" => "string", "description" => "Optional list title"}, + "items" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "List items" + } } } end @@ -847,8 +896,58 @@ defmodule BDS.AI.ChatTools do %{ "type" => "object", "properties" => %{ - "title" => %{"type" => "string"}, - "tabs" => %{"type" => "array"} + "title" => %{"type" => "string", "description" => "Optional tabs title"}, + "tabs" => %{ + "type" => "array", + "description" => "Array of tabs", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string", "description" => "Tab label"}, + "content" => %{ + "type" => "array", + "description" => "Content items within the tab", + "items" => %{ + "type" => "object", + "properties" => %{ + "type" => %{ + "type" => "string", + "enum" => ["text", "metric", "list", "chart", "table"], + "description" => "Content type" + }, + "text" => %{"type" => "string", "description" => "Text content (for type text)"}, + "label" => %{"type" => "string", "description" => "Label (for type metric)"}, + "value" => %{"type" => "string", "description" => "Display value (for type metric)"}, + "title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"}, + "items" => %{ + "type" => "array", "items" => %{"type" => "string"}, + "description" => "Items (for type list)" + }, + "chartType" => %{ + "type" => "string", + "enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"], + "description" => "Chart type (for type chart)" + }, + "series" => %{ + "type" => "array", + "description" => "Data series (for type chart)" + }, + "columns" => %{ + "type" => "array", "items" => %{"type" => "string"}, + "description" => "Column headers (for type table)" + }, + "rows" => %{ + "type" => "array", "items" => %{"type" => "array", "items" => %{"type" => "string"}}, + "description" => "Table rows (for type table)" + } + }, + "required" => ["type"] + } + } + }, + "required" => ["label", "content"] + } + } } } end @@ -857,8 +956,24 @@ defmodule BDS.AI.ChatTools do %{ "type" => "object", "properties" => %{ - "title" => %{"type" => "string"}, - "nodes" => %{"type" => "array"} + "title" => %{"type" => "string", "description" => "Optional mind map title"}, + "nodes" => %{ + "type" => "array", + "description" => "Flat array of nodes. The first node is the root. Each node references children by ID.", + "items" => %{ + "type" => "object", + "properties" => %{ + "id" => %{"type" => "string", "description" => "Unique node identifier"}, + "label" => %{"type" => "string", "description" => "Node label text"}, + "children" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "IDs of child nodes" + } + }, + "required" => ["id", "label"] + } + } } } end diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index bc79fe9..5ccb365 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -684,19 +684,130 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do

<%= @surface.title %>

<% end %>

<%= @surface.chart_type %>

-
- <%= for series <- @surface.series do %> -
-
- <%= series.label %> - <%= series.value %> -
-
- + <%= case @surface.chart_type do %> + <% chart_type when chart_type in ["pie", "donut"] -> %> + <% pie = BDS.Desktop.ShellLive.ChatEditor.ChartView.pie(@surface.series) %> +
+ + <%= for slice <- pie.slices do %> + <%= if slice.full_circle do %> + + <%= slice.label %>: <%= slice.value %> + + <% else %> + + <%= slice.label %>: <%= slice.value %> + + <% end %> + <% end %> + <%= if @surface.chart_type == "donut" do %> + + <%= pie.total %> + <% end %> + +
+ <%= for item <- pie.legend do %> + + + <%= item.label %> + + <% end %>
- <% end %> -
+ + <% chart_type when chart_type in ["line", "area"] -> %> + <% line = BDS.Desktop.ShellLive.ChatEditor.ChartView.line(@surface.series, @surface.chart_type == "area") %> + + <%= for tick <- line.grid do %> + + <%= tick.label %> + <% end %> + <%= if line.area? do %> + + <% end %> + + <%= for dot <- line.dots do %> + + <%= dot.label %>: <%= dot.value %> + + <% end %> + <%= for label <- line.x_labels do %> + <%= label.label %> + <% end %> + + + <% "heatmap" -> %> + <% heat = BDS.Desktop.ShellLive.ChatEditor.ChartView.heatmap(@surface.series) %> + <%= if heat.rows == [] do %> +
+ <%= for series <- @surface.series do %> +
+
+ <%= series.label %> + <%= series.value %> +
+
+ +
+
+ <% end %> +
+ <% else %> +
+ + <%= for col <- heat.columns do %> + <%= col %> + <% end %> + <%= for row <- heat.rows do %> + <%= row.label %> + <%= for cell <- row.cells do %> + <%= if cell.value > 0, do: cell.value %> + <% end %> + <% end %> +
+ <% end %> + + <% "stacked-bar" -> %> + <% stacked = BDS.Desktop.ShellLive.ChatEditor.ChartView.stacked(@surface.series) %> +
+ <%= for row <- stacked.rows do %> +
+
+ <%= row.label %> + <%= row.total %> +
+
+ <%= for seg <- row.segments do %> + + <% end %> +
+
+ <% end %> +
+
+ <%= for item <- stacked.legend do %> + + + <%= item.label %> + + <% end %> +
+ + <% _bar -> %> +
+ <%= for series <- @surface.series do %> +
+
+ <%= series.label %> + <%= series.value %> +
+
+ +
+
+ <% end %> +
+ <% end %> <% "metric" -> %>
diff --git a/lib/bds/desktop/shell_live/chat_editor/chart_view.ex b/lib/bds/desktop/shell_live/chat_editor/chart_view.ex new file mode 100644 index 0000000..888870c --- /dev/null +++ b/lib/bds/desktop/shell_live/chat_editor/chart_view.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex b/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex index 4b17d58..7e3b2fe 100644 --- a/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex +++ b/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex @@ -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) diff --git a/test/bds/desktop/shell_live/chat_editor/chart_view_test.exs b/test/bds/desktop/shell_live/chat_editor/chart_view_test.exs new file mode 100644 index 0000000..e1b8dbf --- /dev/null +++ b/test/bds/desktop/shell_live/chat_editor/chart_view_test.exs @@ -0,0 +1,129 @@ +defmodule Bds.Desktop.ShellLive.ChatEditor.ChartViewTest do + use ExUnit.Case, async: true + + alias BDS.Desktop.ShellLive.ChatEditor.ChartView + + defp entry(label, value, segments \\ []) do + %{label: label, value: value, segments: segments} + end + + defp seg(label, value), do: %{label: label, value: value} + + describe "y_ticks/1" do + test "always starts at zero and reaches the max value" do + ticks = ChartView.y_ticks(100) + assert List.first(ticks) == 0.0 + assert List.last(ticks) >= 100 + end + + test "returns [0] for non-positive maxima" do + assert ChartView.y_ticks(0) == [0] + end + + test "produces nice rounded steps" do + assert ChartView.y_ticks(100) == [0.0, 50.0, 100.0] + end + end + + describe "pie/1" do + test "produces one slice and legend item per series entry" do + result = ChartView.pie([entry("A", 1), entry("B", 3)]) + assert length(result.slices) == 2 + assert length(result.legend) == 2 + assert result.total == 4 + assert [%{d: "M" <> _}, _] = result.slices + end + + test "uses a full circle for a single slice" do + result = ChartView.pie([entry("Only", 5)]) + assert [%{full_circle: true, d: nil}] = result.slices + end + + test "distinct colours come from the palette" do + result = ChartView.pie([entry("A", 1), entry("B", 1)]) + colours = Enum.map(result.slices, & &1.color) + assert colours == Enum.uniq(colours) + end + + test "empty series yields no slices" do + assert %{slices: [], total: 0} = ChartView.pie([]) + end + end + + describe "line/2" do + test "produces a dot and polyline point per entry" do + result = ChartView.line([entry("Jan", 10), entry("Feb", 20)], false) + assert length(result.dots) == 2 + assert result.polyline =~ "," + assert result.area? == false + assert result.area_points == "" + end + + test "area mode builds a closed polygon" do + result = ChartView.line([entry("Jan", 10), entry("Feb", 20)], true) + assert result.area? == true + assert result.area_points != "" + end + + test "gridlines and y labels are derived from ticks" do + result = ChartView.line([entry("Jan", 100)], false) + assert length(result.grid) >= 2 + assert Enum.all?(result.grid, &is_binary(&1.label)) + end + end + + describe "heatmap/1" do + test "builds columns from segment labels and a cell per column" do + series = [ + entry("2024", 0, [seg("Jan", 2), seg("Feb", 4)]), + entry("2025", 0, [seg("Jan", 1), seg("Feb", 8)]) + ] + + result = ChartView.heatmap(series) + assert result.columns == ["Jan", "Feb"] + assert result.column_count == 2 + assert length(result.rows) == 2 + assert Enum.all?(result.rows, &(length(&1.cells) == 2)) + end + + test "zero-valued cells are transparent" do + series = [entry("2024", 0, [seg("Jan", 0), seg("Feb", 5)])] + [row] = ChartView.heatmap(series).rows + assert [%{value: 0, bg: "transparent", fg: "inherit"}, %{value: 5}] = row.cells + end + + test "ignores entries without segments" do + assert %{rows: [], columns: []} = ChartView.heatmap([entry("plain", 5)]) + end + end + + describe "stacked/1" do + test "computes segment widths against the largest stacked total" do + series = [ + entry("2024", 0, [seg("draft", 1), seg("published", 1)]), + entry("2025", 0, [seg("draft", 2), seg("published", 2)]) + ] + + result = ChartView.stacked(series) + assert result.max_total == 4 + assert result.legend == [%{label: "draft", color: ChartView.color(0)}, %{label: "published", color: ChartView.color(1)}] + + first_row = List.first(result.rows) + assert first_row.total == 2 + assert Enum.map(first_row.segments, & &1.width) == ["25", "25"] + end + + test "segment colours are keyed by label, consistent across rows" do + series = [ + entry("a", 0, [seg("x", 1), seg("y", 1)]), + entry("b", 0, [seg("y", 1)]) + ] + + result = ChartView.stacked(series) + [row_a, row_b] = result.rows + y_in_a = Enum.find(row_a.segments, &(&1.label == "y")) + y_in_b = Enum.find(row_b.segments, &(&1.label == "y")) + assert y_in_a.color == y_in_b.color + end + end +end