defmodule PhiaUi.Components.AreaChart do @moduledoc """ Filled area chart — pure SVG, zero JS. Supports a default single/multi-area view and a `:stacked` variant. Areas fade in on entrance; the line strokes animate with dashoffset. ## Examples <.area_chart data={[ %{label: "Jan", value: 80}, %{label: "Feb", value: 140}, %{label: "Mar", value: 110} ]} /> <.area_chart series={[ %{name: "Visitors", data: [%{label: "Mon", value: 100}, %{label: "Tue", value: 200}]}, %{name: "Signups", data: [%{label: "Mon", value: 40}, %{label: "Tue", value: 80}]} ]} variant={:stacked} fill_opacity={0.3} /> """ use Phoenix.Component import PhiaUi.ClassMerger, only: [cn: 1] alias PhiaUi.Components.Data.ChartHelpers alias PhiaUi.Components.Data.ChartAxisHelpers alias PhiaUi.Components.Data.ChartViewport alias PhiaUi.Components.Data.ChartTheme alias PhiaUi.Components.Data.ChartMathHelpers attr :data, :list, default: [], doc: "Single-series: `[%{label, value}]`." attr :series, :list, default: [], doc: "Multi-series: `[%{name, data: [%{label, value}]}]`." attr :variant, :atom, default: :default, values: [:default, :stacked], doc: "`:default` overlays areas; `:stacked` accumulates them." attr :curve, :atom, default: :linear, values: [:linear, :smooth, :monotone, :step_before, :step_after, :step_middle], doc: "Interpolation curve type for the area outline." attr :colors, :list, default: [], doc: "Override default palette." attr :fill_opacity, :float, default: 0.2, doc: "Area fill opacity (0.0–1.0)." attr :show_grid, :boolean, default: true attr :show_labels, :boolean, default: true attr :animate, :boolean, default: true attr :animation_duration, :integer, default: 800 attr :theme, :map, default: %{}, doc: "Chart theme overrides (see ChartTheme)." attr :show_point_labels, :boolean, default: false, doc: "Show value labels above data points." attr :class, :string, default: nil attr :rest, :global def area_chart(assigns) do series = ChartHelpers.normalize_series(assigns.data, assigns.series) vp = ChartViewport.build() theme = ChartTheme.merge(assigns.theme) first_data = series |> List.first(%{data: []}) |> Map.get(:data, []) n_groups = length(first_data) {y_ticks, y_max_nice} = if assigns.variant == :stacked do ChartHelpers.compute_stacked_y_domain(series) else {ticks, _min, max_n} = ChartHelpers.compute_y_domain(series, include_negative: false) {ticks, max_n} end px_bot = (vp.pt + vp.ch) * 1.0 px_top = vp.pt * 1.0 x0 = vp.pl * 1.0 x1 = (vp.pl + vp.cw) * 1.0 curve = assigns.curve series_areas = if assigns.variant == :stacked do build_stacked_areas(series, x0, x1, y_max_nice, px_top, px_bot, n_groups, curve, assigns.colors) else build_overlaid_areas(series, x0, x1, y_max_nice, px_top, px_bot, n_groups, curve, assigns.colors) end # Point labels point_labels = if assigns.show_point_labels do series |> Enum.with_index() |> Enum.flat_map(fn {s, _i} -> s.data |> Enum.with_index() |> Enum.map(fn {item, di} -> px_x = x0 + di / max(n_groups - 1, 1) * vp.cw py_y = px_bot - item.value / max(y_max_nice, 1) * vp.ch %{ x: Float.round(px_x, 2), y: Float.round(py_y - theme.point_label.offset, 2), label: ChartAxisHelpers.format_tick(item.value * 1.0) } end) end) else [] end tick_entries = Enum.map(y_ticks, fn tick -> py = Float.round(px_bot - tick / max(y_max_nice, 1) * vp.ch, 2) %{py: py, label: ChartAxisHelpers.format_tick(tick * 1.0)} end) x_label_entries = first_data |> Enum.with_index() |> Enum.map(fn {item, i} -> px = Float.round(x0 + i / max(n_groups - 1, 1) * vp.cw, 2) %{label: item.label, px: px, py: px_bot + 14} end) assigns = assigns |> assign(:series_areas, series_areas) |> assign(:tick_entries, tick_entries) |> assign(:x_label_entries, x_label_entries) |> assign(:point_labels, point_labels) |> assign(:viewbox, ChartViewport.viewbox(vp)) |> assign(:grid_x1, vp.pl) |> assign(:grid_x2, vp.pl + vp.cw) |> assign(:theme, theme) ~H"""