From a8e6dee5f1d466314cbc24fe1ce04e2014c8e58c Mon Sep 17 00:00:00 2001 From: pnezis Date: Wed, 4 Sep 2024 11:14:07 +0300 Subject: [PATCH] Add Tucan.range_bar/4 plot --- CHANGELOG.md | 17 ++++++- lib/tucan.ex | 107 ++++++++++++++++++++++++++++++++++++++++++++ test/tucan_test.exs | 61 +++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d268950..a2126b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ ### Added -- Add `Tucan.Export` wrapper around `VegaLite.Export` +#### Plots + +- Add `Tucan.range_bar/4` plot + +```tucan +data = [ + %{category: "A", min: 28, max: 55}, + %{category: "B", min: 43, max: 91}, + %{category: "C", min: 13, max: 61} +] + +Tucan.range_bar(data, "category", "min", "max", fill_color: "red") +``` - Add `Tucan.Finance.candlestick/7` specialized plot @@ -26,6 +38,9 @@ Tucan.layers([ |> Tucan.Scale.set_xy_domain(-7, 7) ``` +#### Other + +- Add `Tucan.Export` wrapper around `VegaLite.Export` - Add `Tucan.Axes.set_color/2`, `Tucan.Axes.set_color/3` helpers. - Add `Tucan.Axes.set_title_color/3` helpers. - Add `Tucan.Grid.set_color/2` helper. diff --git a/lib/tucan.ex b/lib/tucan.ex index f9bf694..2b0e3ab 100644 --- a/lib/tucan.ex +++ b/lib/tucan.ex @@ -2142,6 +2142,113 @@ defmodule Tucan do |> maybe_flip_axes(flip_axes?) end + @range_bar_opts Tucan.Options.take!([ + @global_opts, + @global_mark_opts, + :color_by, + :orient, + :x, + :x2, + :y, + :y2, + :x_offset, + :y_offset, + :color, + :fill_color, + :corner_radius + ]) + @range_bar_schema Tucan.Options.to_nimble_schema!(@range_bar_opts) + + @doc """ + Draws a range bar chart. + + A range bar chart is a bar chart where the bar is not a single value, but a range defined by a + `min` and `max` value. It is used for showing intervals or ranges of data. + + ## Options + + #{Tucan.Options.docs(@range_bar_opts)} + + ## Examples + + A simple ranged bar chart: + + ```tucan + data = [ + %{"id" => "A", "min" => 28, "max" => 55}, + %{"id" => "B", "min" => 43, "max" => 91}, + %{"id" => "C", "min" => 13, "max" => 61} + ] + + Tucan.range_bar(data, "id", "min", "max") + ``` + + You can set a `color_by` option that will create a stacked ranged bar chart: + + ```tucan + data = [ + %{"product" => "Laptop", "category" => "Basic", "min_price" => 499, "max_price" => 799}, + %{"product" => "Laptop", "category" => "Regular", "min_price" => 999, "max_price" => 1799}, + %{"product" => "Laptop", "category" => "Premium", "min_price" => 1499, "max_price" => 2499}, + %{"product" => "Smartphone", "category" => "Basic", "min_price" => 199, "max_price" => 399}, + %{"product" => "Smartphone", "category" => "Regular", "min_price" => 399, "max_price" => 699}, + %{"product" => "Smartphone", "category" => "Premium", "min_price" => 699, "max_price" => 1299}, + %{"product" => "Tablet", "category" => "Basic", "min_price" => 299, "max_price" => 499}, + %{"product" => "Tablet", "category" => "Regular", "min_price" => 399, "max_price" => 699}, + %{"product" => "Tablet", "category" => "Premium", "min_price" => 599, "max_price" => 1990} + ] + + Tucan.range_bar(data, "product", "min_price", "max_price", + color_by: "category", + tooltip: true + ) + ``` + + You can tweak the look of the chart using the supported options: + + ```tucan + data = [ + %{"id" => "A", "min" => 28, "max" => 55}, + %{"id" => "B", "min" => 43, "max" => 91}, + %{"id" => "C", "min" => 13, "max" => 61} + ] + + Tucan.range_bar(data, "id", "min", "max", orient: :vertical, fill_color: "#33245A", corner_radius: 5) + ``` + """ + @doc section: :plots + @spec range_bar( + plotdata :: plotdata(), + field :: String.t(), + min :: String.t(), + max :: String.t(), + opts :: keyword() + ) :: + VegaLite.t() + def range_bar(plotdata, field, min, max, opts \\ []) do + opts = NimbleOptions.validate!(opts, @range_bar_schema) + + flip_axes? = opts[:orient] == :vertical + opts = maybe_flip_encoding_options(opts, flip_axes?) + + spec_opts = Tucan.Options.take_options(opts, @range_bar_opts, :spec) + + mark_opts = + Tucan.Options.take_options(opts, @range_bar_opts, :mark) + |> Tucan.Keyword.put_not_nil(:color, opts[:fill_color]) + |> Tucan.Keyword.put_not_nil(:corner_radius_end, opts[:corner_radius]) + + plotdata + |> new(spec_opts) + |> Vl.mark(:bar, mark_opts) + |> encode_field(:y, field, opts, type: :nominal, axis: [label_angle: 0]) + |> encode_field(:x, min, opts, type: :quantitative) + |> encode_field(:x2, max, opts, type: :quantitative) + |> maybe_encode_field(:color, fn -> opts[:color_by] != nil end, opts[:color_by], opts, []) + |> maybe_encode_field(:y_offset, fn -> opts[:color_by] != nil end, opts[:color_by], opts, []) + |> maybe_flip_axes(flip_axes?) + end + lollipop_opts = [ group_by: [ type: :string, diff --git a/test/tucan_test.exs b/test/tucan_test.exs index 5f32a88..7133dad 100644 --- a/test/tucan_test.exs +++ b/test/tucan_test.exs @@ -1703,6 +1703,67 @@ defmodule TucanTest do end end + describe "range_bar/4" do + test "with default options" do + data = [ + %{category: "A", min: 28, max: 55}, + %{category: "B", min: 43, max: 91}, + %{category: "C", min: 13, max: 61} + ] + + expected = + Vl.new() + |> Vl.data_from_values(data) + |> Vl.mark(:bar, fill_opacity: 1.0) + |> Vl.encode_field(:y, "category", type: :nominal, axis: [label_angle: 0]) + |> Vl.encode_field(:x, "min", type: :quantitative) + |> Vl.encode_field(:x2, "max", type: :quantitative) + + assert Tucan.range_bar(data, "category", "min", "max") == expected + end + + test "with orient flag set" do + data = [ + %{category: "A", min: 28, max: 55}, + %{category: "B", min: 43, max: 91}, + %{category: "C", min: 13, max: 61} + ] + + expected = + Vl.new() + |> Vl.data_from_values(data) + |> Vl.mark(:bar, fill_opacity: 1.0) + |> Vl.encode_field(:x, "category", type: :nominal, axis: [label_angle: 0]) + |> Vl.encode_field(:y, "min", type: :quantitative) + |> Vl.encode_field(:y2, "max", type: :quantitative) + + assert Tucan.range_bar(data, "category", "min", "max", orient: :vertical) == expected + end + + test "with color_by set and custom options" do + data = [ + %{category: "A", min: 28, max: 55}, + %{category: "B", min: 43, max: 91}, + %{category: "C", min: 13, max: 61} + ] + + expected = + Vl.new() + |> Vl.data_from_values(data) + |> Vl.mark(:bar, fill_opacity: 1.0, color: "red") + |> Vl.encode_field(:y, "category", type: :nominal, axis: [label_angle: 0]) + |> Vl.encode_field(:x, "min", type: :quantitative) + |> Vl.encode_field(:x2, "max", type: :quantitative) + |> Vl.encode_field(:y_offset, "category") + |> Vl.encode_field(:color, "category") + + assert Tucan.range_bar(data, "category", "min", "max", + color_by: "category", + fill_color: "red" + ) == expected + end + end + describe "lollipop/4" do test "with default options" do data = [