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"""
""" end # Overlaid areas — each series area goes from y=0 baseline defp build_overlaid_areas(series, x0, x1, y_max, px_top, px_bot, _n_groups, curve, colors) do series |> Enum.with_index() |> Enum.map(fn {s, i} -> color = ChartHelpers.chart_color(i, colors) if curve == :linear do pts = ChartHelpers.series_points(s.data, x0, x1, 0, y_max, px_top, px_bot) line_len = ChartHelpers.polyline_length(pts) area_path = build_linear_area_path(s.data, x0, x1, y_max, px_top, px_bot) %{points: pts, line_path: nil, area_path: area_path, line_len: Float.round(line_len, 2), color: color, delay_ms: i * 100, curved: false} else line_path = ChartHelpers.series_path(s.data, x0, x1, 0, y_max, px_top, px_bot, curve) area_path = ChartHelpers.series_area_path(s.data, x0, x1, 0, y_max, px_top, px_bot, curve) line_len = ChartHelpers.path_length(line_path) %{points: nil, line_path: line_path, area_path: area_path, line_len: Float.round(line_len, 2), color: color, delay_ms: i * 100, curved: true} end end) end # Stacked areas — each series baseline follows the previous series' top defp build_stacked_areas(series, x0, x1, y_max, px_top, px_bot, _n_groups, _curve, colors) do stacked = ChartMathHelpers.stack_series(series) y_range = max(y_max, 1) px_range = px_bot - px_top stacked |> Enum.with_index() |> Enum.map(fn {s, i} -> color = ChartHelpers.chart_color(i, colors) count = length(s.data) top_points = s.data |> Enum.with_index() |> Enum.map(fn {item, di} -> px_x = x0 + di / max(count - 1, 1) * (x1 - x0) px_y = px_bot - (item.base + item.value) / y_range * px_range {Float.round(px_x, 2), Float.round(px_y, 2)} end) base_points = s.data |> Enum.with_index() |> Enum.map(fn {item, di} -> px_x = x0 + di / max(count - 1, 1) * (x1 - x0) px_y = px_bot - item.base / y_range * px_range {Float.round(px_x, 2), Float.round(px_y, 2)} end) |> Enum.reverse() # Area path: top line forward, base line backward top_part = Enum.map_join(top_points, " L ", fn {x, y} -> "#{x} #{y}" end) base_part = Enum.map_join(base_points, " L ", fn {x, y} -> "#{x} #{y}" end) {fx, fy} = List.first(top_points) area_path = "M #{fx} #{fy} L #{top_part} L #{base_part} Z" # Line points (top edge only) pts = Enum.map_join(top_points, " ", fn {x, y} -> "#{x},#{y}" end) line_len = ChartHelpers.polyline_length(pts) %{points: pts, line_path: nil, area_path: area_path, line_len: Float.round(line_len, 2), color: color, delay_ms: i * 100, curved: false} end) end defp build_linear_area_path([], _x0, _x1, _y_max, _px_top, _px_bot), do: "" defp build_linear_area_path(data, x0, x1, y_max, px_top, px_bot) do count = length(data) y_range = max(y_max, 1) px_range = px_bot - px_top pts = data |> Enum.with_index() |> Enum.map(fn {item, i} -> px_x = x0 + i / max(count - 1, 1) * (x1 - x0) px_y = px_bot - item.value / y_range * px_range {Float.round(px_x, 2), Float.round(px_y, 2)} end) {first_x, _} = List.first(pts) {last_x, _} = List.last(pts) line_part = Enum.map_join(pts, " L ", fn {x, y} -> "#{x} #{y}" end) "M #{first_x} #{px_bot} L #{line_part} L #{last_x} #{px_bot} Z" end end