lib/vega_lite.ex

defmodule VegaLite do
  @moduledoc """
  Elixir bindings to [Vega-Lite](https://vega.github.io/vega-lite).

  Vega-Lite offers a high-level grammar for composing interactive graphics,
  where every graphic is specified in a declarative fashion relying solely
  on JSON syntax. To learn more about Vega-Lite please refer to
  the [documentation](https://vega.github.io/vega-lite/docs)
  and explore numerous [examples](https://vega.github.io/vega-lite/examples).

  This package offers a tiny layer of functionality that makes it easier
  to build a Vega-Lite graphics specification.

  ## Composing graphics

  We offers a light-weight pipeline API akin to the JSON specification.
  Translating existing Vega-Lite specifications to such specification
  should be very intuitive in most cases.

  Composing a basic Vega-Lite graphic usually consists of the following steps:

      alias VegaLite, as: Vl

      # Initialize the specification, optionally with some top-level properties
      Vl.new(width: 400, height: 400)

      # Specify data source for the graphic, see the data_from_* functions
      |> Vl.data_from_values(iteration: 1..100, score: 1..100)
      # |> Vl.data_from_values([%{iteration: 1, score: 1}, ...])
      # |> Vl.data_from_url("...")

      # Pick a visual mark for the graphic
      |> Vl.mark(:line)
      # |> Vl.mark(:point, tooltip: true)

      # Map data fields to visual properties of the mark, like position or shape
      |> Vl.encode_field(:x, "iteration", type: :quantitative)
      |> Vl.encode_field(:y, "score", type: :quantitative)
      # |> Vl.encode(:color, "country", type: :nominal)
      # |> Vl.encode(:size, "count", type: :quantitative)

  Then, you can compose multiple graphics using `layers/2`, `concat/3`,
  `repeat/3` or `facet/3`.

      Vl.new()
      |> Vl.data_from_url("https://vega.github.io/editor/data/weather.csv")
      |> Vl.transform(filter: "datum.location == 'Seattle'")
      |> Vl.concat([
        Vl.new()
        |> Vl.mark(:bar)
        |> Vl.encode_field(:x, "date", time_unit: :month, type: :ordinal)
        |> Vl.encode_field(:y, "precipitation", aggregate: :mean),
        Vl.new()
        |> Vl.mark(:point)
        |> Vl.encode_field(:x, "temp_min", bin: true)
        |> Vl.encode_field(:y, "temp_max", bin: true)
        |> Vl.encode(:size, aggregate: :count)
      ])

  Additionally, you can use `transform/2` to preprocess the data,
  `param/3` for introducing interactivity and `config/2` for
  global customization.

  ### Using JSON specification

  Alternatively you can parse a Vega-Lite JSON specification directly.
  This approach makes it easy to explore numerous examples available online.

      alias VegaLite, as: Vl

      Vl.from_json(\"\"\"
      {
        "data": { "url": "https://vega.github.io/editor/data/cars.json" },
        "mark": "point",
        "encoding": {
          "x": { "field": "Horsepower", "type": "quantitative" },
          "y": { "field": "Miles_per_Gallon", "type": "quantitative" }
        }
      }
      \"\"\")

  The result of `VegaLite.from_json/1` function can then be passed
  through any other function to further customize the specification.
  In particular, it may be useful to parse a JSON specification
  and add your custom data with `VegaLite.data_from_values/3`.

  ## Options

  Most `VegaLite` functions accept an optional list of options,
  which are converted directly as the specification properties.
  To provide a more Elixir-friendly experience, the options
  are automatically normalized, so you can use keyword lists
  and snake-case atom keys. For example, if you specify
  `axis: [label_angle: -45]`, this library will automatically
  rewrite it `labelAngle`, which is the name used by the VegaLite
  specification.
  """

  @schema_url "https://vega.github.io/schema/vega-lite/v5.json"

  defstruct spec: %{"$schema" => @schema_url}

  alias VegaLite.Utils

  @type t :: %__MODULE__{
          spec: spec()
        }

  @type spec :: map()

  @doc """
  Returns a new specification wrapped in the `VegaLite` struct.

  All provided options are converted to top-level properties
  of the specification.

  ## Examples

      Vl.new(
        title: "My graph",
        width: 200,
        height: 200
      )
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/spec.html) for more details.
  """
  @spec new(keyword()) :: t()
  def new(opts \\ []) do
    vl = %VegaLite{}
    vl_props = opts_to_vl_props(opts)
    update_in(vl.spec, fn spec -> Map.merge(spec, vl_props) end)
  end

  @compile {:no_warn_undefined, {Jason, :decode!, 1}}

  @doc """
  Parses the given Vega-Lite JSON specification
  and wraps in the `VegaLite` struct for further processing.

  ## Examples

      Vl.from_json(\"\"\"
      {
        "data": { "url": "https://vega.github.io/editor/data/cars.json" },
        "mark": "point",
        "encoding": {
          "x": { "field": "Horsepower", "type": "quantitative" },
          "y": { "field": "Miles_per_Gallon", "type": "quantitative" }
        }
      }
      \"\"\")


  See [the docs](https://vega.github.io/vega-lite/docs/spec.html) for more details.
  """
  @spec from_json(String.t()) :: t()
  def from_json(json) do
    Utils.assert_jason!("from_json/1")

    json
    |> Jason.decode!()
    |> from_spec()
  end

  @doc """
  Wraps the given Vega-Lite specification in the `VegaLite`
  struct for further processing.

  There is also `from_json/1` that handles JSON parsing for you.

  See [the docs](https://vega.github.io/vega-lite/docs/spec.html) for more details.
  """
  @spec from_spec(spec()) :: t()
  def from_spec(spec) do
    %VegaLite{spec: spec}
  end

  @doc """
  Returns the underlying Vega-Lite specification.

  The result is a nested Elixir datastructure that serializes
  to Vega-Lite JSON specification.

  See [the docs](https://vega.github.io/vega-lite/docs/spec.html) for more details.
  """
  @spec to_spec(t()) :: spec()
  def to_spec(vl) do
    vl.spec
  end

  @doc """
  Sets data properties in the specification.

  Defining the data source is usually the first step
  when building a graphic. For most use cases it's preferable
  to use more specific functions like `data_from_url/3` or `data_from_values/3`.

  All provided options are converted to data properties.

  ## Examples

      Vl.new()
      |> Vl.data(sequence: [start: 0, stop: 12.7, step: 0.1, as: "x"])
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/data.html) for more details.
  """
  @spec data(t(), keyword()) :: t()
  def data(vl, opts) do
    validate_at_least_one!(opts, "data property")

    update_in(vl.spec, fn spec ->
      {values, opts} = Keyword.pop(opts, :values)
      vl_props = opts_to_vl_props(opts)
      vl_props = if(values, do: Map.put(vl_props, "values", values), else: vl_props)
      Map.put(spec, "data", vl_props)
    end)
  end

  defp validate_at_least_one!(opts, name) do
    if not is_list(opts) do
      raise ArgumentError, "expected opts to be a list, got: #{inspect(opts)}"
    end

    if opts == [] do
      raise ArgumentError, "expected at least one #{name}, but none was given"
    end
  end

  @doc """
  Sets data URL in the specification.

  The URL should be accessible by whichever client renders
  the specification, so preferably an absolute one.

  All provided options are converted to data properties.

  ## Examples

      Vl.new()
      |> Vl.data_from_url("https://vega.github.io/editor/data/penguins.json")
      |> ...

      Vl.new()
      |> Vl.data_from_url("https://vega.github.io/editor/data/stocks.csv", format: :csv)
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/data.html#url) for more details.
  """
  @spec data_from_url(t(), String.t(), keyword()) :: t()
  def data_from_url(vl, url, opts \\ []) when is_binary(url) do
    opts = put_in(opts[:url], url)
    data(vl, opts)
  end

  @doc """
  Sets inline data in the specification.

  Any tabular data is accepted, as long as it adheres to the
  `Table.Reader` protocol.

  ## Options

    * `:only` - specifies a subset of fields to pick from the data

  All other options are converted to data properties.

  ## Examples

      data = [
        %{"category" => "A", "score" => 28},
        %{"category" => "B", "score" => 55}
      ]

      Vl.new()
      |> Vl.data_from_values(data)
      |> ...

  Note that any tabular data is accepted, as long as it adheres
  to the `Table.Reader` protocol. For example that's how we can
  pass individual series:

      xs = 1..100
      ys = 1..100

      Vl.new()
      |> Vl.data_from_values(x: xs, y: ys)
      |> ...

  See [the docs](https://vega.github.io/vega-lite/docs/data.html#inline) for more details.
  """
  @spec data_from_values(t(), Table.Reader.t(), keyword()) :: t()
  def data_from_values(vl, values, opts \\ []) do
    {only, opts} = Keyword.pop(opts, :only)
    values = tabular_to_data_values(values, only)
    opts = put_in(opts[:values], values)
    data(vl, opts)
  end

  # Converts enumerable data structure into Vega-Lite compatible data points
  defp tabular_to_data_values(tabular, only) do
    only = only && only |> Enum.map(&to_string/1) |> MapSet.new()

    tabular
    |> Table.to_rows()
    |> Enum.map(fn entry ->
      for {key, value} <- entry,
          key = to_string(key),
          only == nil or MapSet.member?(only, key),
          into: %{},
          do: {key, value}
    end)
  end

  @doc false
  @deprecated "Use VegaLite.data_from_values/3 instead"
  def data_from_series(vl, series, opts \\ []) do
    {keys, value_series} = Enum.unzip(series)

    values =
      value_series
      |> Enum.zip()
      |> Enum.map(fn row ->
        row = Tuple.to_list(row)
        Enum.zip(keys, row)
      end)

    data_from_values(vl, values, opts)
  end

  @doc """
  Specifies top-level datasets.

  Datasets can be used as a data source further in the specification.
  This is useful if you need to refer to the data in multiple places
  or use a `transform/2` like `:lookup`.

  Datasets should be a key-value enumerable, where key is the dataset
  name and value is tabular data as in `data_from_values/3`.

  ## Examples

      results = [
        %{"category" => "A", "score" => 28},
        %{"category" => "B", "score" => 55}
      ]

      points = [
        %{"x" => "1", "y" => 10},
        %{"x" => "2", "y" => 100}
      ]

      Vl.new()
      |> Vl.datasets_from_values(results: results, points: points)
      # Use one of the data sets as the primary data
      |> Vl.data(name: "results")
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/data.html#datasets) for more details.
  """
  @spec datasets_from_values(t(), Enumerable.t()) :: t()
  def datasets_from_values(vl, datasets) do
    datasets =
      for {name, values} <- datasets, into: %{} do
        values = tabular_to_data_values(values, nil)
        {to_string(name), values}
      end

    put_in(vl.spec["datasets"], datasets)
  end

  @channels ~w(
    x y x2 y2 x_error y_error x_error2 y_error2
    x_offset y_offset
    theta theta2 radius radius2
    longitude latitude longitude2 latitude2
    angle color fill stroke opacity fill_opacity stroke_opacity shape size stroke_dash stroke_width
    text tooltip
    href
    url
    description
    detail
    key
    order
    facet row column
  )a

  @doc """
  Adds an encoding entry to the specification.

  Visual channel represents a property of a visual mark,
  for instance the `:x` and `:y` channels specify where
  a point should be placed.
  Encoding defines the source of values for those channels.

  In most cases you want to map specific data field
  to visual channels, prefer the `encode_field/4` function for that.

  All provided options are converted to channel properties.

  ## Examples

      Vl.new()
      |> Vl.encode(:x, value: 2)
      |> ...

      Vl.new()
      |> Vl.encode(:y, aggregate: :count, type: :quantitative)
      |> ...

      Vl.new()
      |> Vl.encode(:y, field: "price")
      |> ...

  Alternatively, a list of property lists may be given:

      Vl.new()
      |> Vl.encode(:tooltip, [
        [field: "height", type: :quantitative],
        [field: "width", type: :quantitative]
      ])
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/encoding.html) for more details.
  """
  @spec encode(t(), atom(), keyword() | list(keyword())) :: t()
  def encode(vl, channel, opts) do
    validate_channel!(channel)

    list? = match?([h | _] when is_list(h), opts)

    if list? do
      for opts <- opts, do: validate_channel_opts(opts)
    else
      validate_channel_opts(opts)
    end

    update_in(vl.spec, fn spec ->
      vl_channel = to_vl_key(channel)

      vl_props =
        if list? do
          Enum.map(opts, &opts_to_vl_props/1)
        else
          opts_to_vl_props(opts)
        end

      encoding =
        spec
        |> Map.get("encoding", %{})
        |> Map.put(vl_channel, vl_props)

      Map.put(spec, "encoding", encoding)
    end)
  end

  defp validate_channel!(channel) do
    validate_inclusion!(@channels, channel, "channel")
  end

  defp validate_inclusion!(list, value, name) do
    if value not in list do
      list_str = list |> Enum.map(&inspect/1) |> Enum.join(", ")

      raise ArgumentError,
            "unknown #{name}, expected one of #{list_str}, got: #{inspect(value)}"
    end
  end

  defp validate_channel_opts(opts) do
    if not Enum.any?([:field, :value, :datum], &Keyword.has_key?(opts, &1)) and
         opts[:aggregate] != :count do
      raise ArgumentError,
            "channel definition must include one of the following keys: :field, :value, :datum, but none was given"
    end

    with {:ok, type} <- Keyword.fetch(opts, :type) do
      validate_inclusion!([:quantitative, :temporal, :nominal, :ordinal, :geojson], type, "type")
    end
  end

  @doc """
  Adds field encoding entry to the specification.

  A shorthand for `encode/3`, mapping a data field to a visual channel.

  For example, if the data has `"price"` and `"time"` fields,
  you could map `"time"` to the `:x` channel and `"price"`
  to the `:y` channel. This, combined with a line mark,
  would then result in price-over-time plot.

  All provided options are converted to channel properties.

  ## Types

  Field data type is automatically inferred, but oftentimes
  needs to be specified explicitly to get the desired result.
  The `:type` option can be either of:

    * `:quantitative` - when the field expresses some kind of quantity, typically numerical

    * `:temporal` - when the field represents a point in time

    * `:nominal` - when the field represents a category

    * `:ordinal` - when the field represents a ranked order.
      It is similar to `:nominal`, but there is a clear order of values

    * `:geojson` - when the field represents a geographic shape
      adhering to the [GeoJSON](https://geojson.org) specification

  See [the docs](https://vega.github.io/vega-lite/docs/type.html) for more details on types.

  ## Examples

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.mark(:point)
      |> Vl.encode_field(:x, "time", type: :temporal)
      |> Vl.encode_field(:y, "price", type: :quantitative)
      |> Vl.encode_field(:color, "country", type: :nominal)
      |> Vl.encode_field(:size, "count", type: :quantitative)
      |> ...

      Vl.new()
      |> Vl.encode_field(:x, "date", time_unit: :month, title: "Month")
      |> Vl.encode_field(:y, "price", type: :quantitative, aggregate: :mean, title: "Mean product price")
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/encoding.html#field-def) for more details.
  """
  @spec encode_field(t(), atom(), String.t(), keyword()) :: t()
  def encode_field(vl, channel, field, opts \\ []) do
    if not is_binary(field) do
      raise ArgumentError, "field must be a string, got: #{inspect(field)}"
    end

    opts = put_in(opts[:field], field)
    encode(vl, channel, opts)
  end

  @doc """
  Adds repeated field encoding entry to the specification.

  A shorthand for `encode/3`, mapping a field to a visual channel,
  as given by the repeat operator.

  Repeat type must be either `:repeat`, `:row`, `:column` or `:layer`
  and correspond to the repeat definition.

  All provided options are converted to channel properties.

  ## Examples

  See `repeat/3` to see the full picture.

  See [the docs](https://vega.github.io/vega-lite/docs/repeat.html) for more details.
  """
  @spec encode_repeat(t(), atom(), :repeat | :row | :column | :layer, keyword()) :: t()
  def encode_repeat(vl, channel, repeat_type, opts \\ []) do
    validate_inclusion!([:repeat, :row, :column, :layer], repeat_type, "repeat type")

    opts = Keyword.put(opts, :field, repeat: repeat_type)
    encode(vl, channel, opts)
  end

  @mark_types ~w(
    arc area bar boxplot circle errorband errorbar geoshape image line point rect rule square text tick trail
  )a

  @doc """
  Sets mark type in the specification.

  Mark is a predefined visual object like a point or a line.
  Visual properties of the mark are defined by encoding.

  All provided options are converted to mark properties.

  ## Examples

      Vl.new()
      |> Vl.mark(:point)
      |> ...

      Vl.new()
      |> Vl.mark(:point, tooltip: true)
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/mark.html) for more details.
  """
  @spec mark(t(), atom(), keyword()) :: t()
  def mark(vl, type, opts \\ [])

  def mark(vl, type, []) do
    validate_blank_view!(vl, "cannot add mark to the view")
    validate_inclusion!(@mark_types, type, "mark type")

    update_in(vl.spec, fn spec ->
      vl_type = to_vl_key(type)
      Map.put(spec, "mark", vl_type)
    end)
  end

  def mark(vl, type, opts) do
    validate_blank_view!(vl, "cannot add mark to the view")
    validate_inclusion!(@mark_types, type, "mark type")

    update_in(vl.spec, fn spec ->
      vl_type = to_vl_key(type)

      vl_props =
        opts
        |> opts_to_vl_props()
        |> Map.put("type", vl_type)

      Map.put(spec, "mark", vl_props)
    end)
  end

  @doc """
  Adds a transformation to the specification.

  Transformation describes an operation on data,
  like calculating new fields, aggregating or filtering.

  All provided options are converted to transform properties.

  ## Examples

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.transform(calculate: "sin(datum.x)", as: "sin_x")
      |> ...

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.transform(filter: "datum.height > 150")
      |> ...

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.transform(regression: "price", on: "date")
      |> ...


  See [the docs](https://vega.github.io/vega-lite/docs/transform.html) for more details.
  """
  @spec transform(t(), keyword()) :: t()
  def transform(vl, opts) do
    validate_at_least_one!(opts, "transform property")

    update_in(vl.spec, fn spec ->
      transforms = Map.get(spec, "transform", [])
      transform = opts_to_vl_props(opts)
      Map.put(spec, "transform", transforms ++ [transform])
    end)
  end

  @doc """
  Adds a parameter to the specification.

  Parameters are the basic building blocks for introducing
  interactions to graphics.

  All provided options are converted to parameter properties.

  ## Examples

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.concat([
        Vl.new()
        # Define a parameter named "brush", whose value is a user-selected interval on the x axis
        |> Vl.param("brush", select: [type: :interval, encodings: [:x]])
        |> Vl.mark(:area)
        |> Vl.encode_field(:x, "date", type: :temporal)
        |> ...,
        Vl.new()
        |> Vl.mark(:area)
        # Use the "brush" parameter value to limit the domain of this view
        |> Vl.encode_field(:x, "date", type: :temporal, scale: [domain: [param: "brush"]])
        |> ...
      ])


  See [the docs](https://vega.github.io/vega-lite/docs/parameter.html) for more details.
  """
  @spec param(t(), String.t(), keyword()) :: t()
  def param(vl, name, opts) do
    validate_at_least_one!(opts, "parameter property")

    update_in(vl.spec, fn spec ->
      params = Map.get(spec, "params", [])

      param =
        opts
        |> opts_to_vl_props()
        |> Map.put("name", name)

      Map.put(spec, "params", params ++ [param])
    end)
  end

  @doc """
  Adds view configuration to the specification.

  Configuration allows for setting general properties of the visualization.

  All provided options are converted to configuration properties
  and merged with the existing configuration in a shallow manner.

  ## Examples

      Vl.new()
      |> ...
      |> Vl.config(
        view: [stroke: :transparent],
        padding: 100,
        background: "#333333"
      )


  See [the docs](https://vega.github.io/vega-lite/docs/config.html) for more details.
  """
  @spec config(t(), keyword()) :: t()
  def config(vl, opts) do
    validate_at_least_one!(opts, "config property")

    update_in(vl.spec, fn spec ->
      vl_props = opts_to_vl_props(opts)

      config =
        spec
        |> Map.get("config", %{})
        |> Map.merge(vl_props)

      Map.put(spec, "config", config)
    end)
  end

  @doc """
  Adds a projection spec to the specification.

  Projection maps longitude and latitude pairs to x, y coordinates.

  ## Examples

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.projection(type: :albers_usa)
      |> Vl.mark(:circle)
      |> Vl.encode_field(:longitude, "longitude", type: :quantitative)
      |> Vl.encode_field(:latitude, "latitude", type: :quantitative)


  See [the docs](https://vega.github.io/vega-lite/docs/projection.html) for more details.
  """
  @spec projection(t(), keyword()) :: t()
  def projection(vl, opts) do
    validate_at_least_one!(opts, "projection property")

    update_in(vl.spec, fn spec ->
      vl_props = opts_to_vl_props(opts)
      Map.put(spec, "projection", vl_props)
    end)
  end

  @doc """
  Builds a layered multi-view specification from the given
  list of single view specifications.

  ## Examples

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.layers([
        Vl.new()
        |> Vl.mark(:line)
        |> Vl.encode_field(:x, ...)
        |> Vl.encode_field(:y, ...),
        Vl.new()
        |> Vl.mark(:rule)
        |> Vl.encode_field(:y, ...)
        |> Vl.encode(:size, value: 2)
      ])

      Vl.new()
      |> Vl.data_from_values(...)
      # Note: top-level data, encoding, transforms are inherited
      # by the child views unless overridden
      |> Vl.encode_field(:x, ...)
      |> Vl.layers([
        ...
      ])


  See [the docs](https://vega.github.io/vega-lite/docs/layer.html) for more details.
  """
  @spec layers(t(), list(t())) :: t()
  def layers(vl, child_views) do
    multi_view_from_children(vl, child_views, "layer", "cannot build a layered view")
  end

  @doc """
  Builds a concatenated multi-view specification from
  the given list of single view specifications.

  The concat type must be either `:wrappable` (default), `:horizontal` or `:vertical`.

  ## Examples

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.concat([
        Vl.new()
        |> ...,
        Vl.new()
        |> ...,
        Vl.new()
        |> ...
      ])

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.concat(
        [
          Vl.new()
          |> ...,
          Vl.new()
          |> ...
        ],
        :horizontal
      )


  See [the docs](https://vega.github.io/vega-lite/docs/concat.html) for more details.
  """
  @spec concat(t(), list(t()), :wrappable | :horizontal | :vertical) :: t()
  def concat(vl, child_views, type \\ :wrappable) do
    vl_key =
      case type do
        :wrappable ->
          "concat"

        :horizontal ->
          "hconcat"

        :vertical ->
          "vconcat"

        type ->
          raise ArgumentError,
                "invalid concat type, expected :wrappable, :horizontal or :vertical, got: #{inspect(type)}"
      end

    multi_view_from_children(vl, child_views, vl_key, "cannot build a concatenated view")
  end

  defp multi_view_from_children(vl, child_views, vl_key, error_message) do
    validate_blank_view!(vl, error_message)

    child_specs = Enum.map(child_views, &to_child_view_spec!/1)

    update_in(vl.spec, fn spec ->
      Map.put(spec, vl_key, child_specs)
    end)
  end

  @doc """
  Builds a facet multi-view specification from the given
  single-view template.

  Facet definition must be either a [field definition](https://vega.github.io/vega-lite/docs/facet.html#field-def)
  or a [row/column mapping](https://vega.github.io/vega-lite/docs/facet.html#mapping).

  Note that you can also create facet graphics by using
  the `:facet`, `:column` and `:row` encoding channels.

  ## Examples

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.facet(
        [field: "country"],
        Vl.new()
        |> Vl.mark(:bar)
        |> Vl.encode_field(:x, ...)
        |> Vl.encode_field(:y, ...)
      )

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.facet(
        [
          row: [field: "country", title: "Country"],
          column: [field: "year", title: "Year"]
        ]
        Vl.new()
        |> Vl.mark(:bar)
        |> Vl.encode_field(:x, ...)
        |> Vl.encode_field(:y, ...)
      )


  See [the docs](https://vega.github.io/vega-lite/docs/facet.html#facet-operator) for more details.
  """
  @spec facet(t(), keyword(), t()) :: t()
  def facet(vl, facet_def, child_view) do
    validate_blank_view!(vl, "cannot build a facet view")

    vl_facet =
      cond do
        Keyword.keyword?(facet_def) and
            Enum.any?([:field, :row, :column], &Keyword.has_key?(facet_def, &1)) ->
          opts_to_vl_props(facet_def)

        true ->
          raise ArgumentError,
                "facet definition must be either a field definition (keyword list with the :field key) or a mapping with :row/:column keys, got: #{inspect(facet_def)}"
      end

    child_spec = to_child_view_spec!(child_view)

    update_in(vl.spec, fn spec ->
      spec
      |> Map.put("facet", vl_facet)
      |> Map.put("spec", child_spec)
    end)
  end

  @doc """
  Builds a repeated multi-view specification from the given
  single-view template.

  Repeat definition must be either a list of fields
  or a [row/column/layer mapping](https://vega.github.io/vega-lite/docs/repeat.html#repeat-mapping).
  Then some channels can be bound to a repeated field using `encode_repeat/4`.

  ## Examples

      # Simple repeat
      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.repeat(
        ["temp_max", "precipitation", "wind"],
        Vl.new()
        |> Vl.mark(:line)
        |> Vl.encode_field(:x, "date", time_unit: :month)
        # The graphic will be reapeated with :y mapped to "temp_max",
        # "precipitation" and "wind" respectively
        |> Vl.encode_repeat(:y, :repeat, aggregate: :mean)
      )

      # Grid repeat
      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.repeat(
        [
          row: [
            "beak_length",
            "beak_depth",
            "flipper_length",
            "body_mass"
          ],
          column: [
            "body_mass",
            "flipper_length",
            "beak_depth",
            "beak_length"
          ]
        ],
        Vl.new()
        |> Vl.mark(:point)
        # The graphic will be repeated for every combination of :x and :y
        # taken from the :row and :column lists above
        |> Vl.encode_repeat(:x, :column, type: :quantitative)
        |> Vl.encode_repeat(:y, :row, type: :quantitative)
      )


  See [the docs](https://vega.github.io/vega-lite/docs/repeat.html) for more details.
  """
  @spec repeat(t(), keyword(), t()) :: t()
  def repeat(vl, repeat_def, child_view) do
    validate_blank_view!(vl, "cannot build a repeated view")

    vl_repeat =
      cond do
        is_list(repeat_def) and Enum.all?(repeat_def, &is_binary/1) ->
          repeat_def

        Keyword.keyword?(repeat_def) and
            Enum.any?([:row, :column, :layer], &Keyword.has_key?(repeat_def, &1)) ->
          opts_to_vl_props(repeat_def)

        true ->
          raise ArgumentError,
                "repeat definition must be either list of fields or a mapping with :row/:column/:layer keys, got: #{inspect(repeat_def)}"
      end

    child_spec = to_child_view_spec!(child_view)

    update_in(vl.spec, fn spec ->
      spec
      |> Map.put("repeat", vl_repeat)
      |> Map.put("spec", child_spec)
    end)
  end

  @single_view_only_keys ~w(mark)a
  @multi_view_only_keys ~w(layer hconcat vconcat concat repeat facet spec)a

  # Validates if the given specification is already either single-view or multi-view
  defp validate_blank_view!(vl, error_message) do
    for key <- @single_view_only_keys, Map.has_key?(vl.spec, to_vl_key(key)) do
      raise ArgumentError,
            "#{error_message}, because it is already a single-view specification (has the #{inspect(key)} key defined)"
    end

    for key <- @multi_view_only_keys, Map.has_key?(vl.spec, to_vl_key(key)) do
      raise ArgumentError,
            "#{error_message}, because it is already a multi-view specification (has the #{inspect(key)} key defined)"
    end
  end

  @top_level_keys ~w($schema background padding autosize config usermeta)a

  defp to_child_view_spec!(vl) do
    spec = vl |> to_spec() |> Map.delete("$schema")

    for key <- @top_level_keys, Map.has_key?(spec, to_vl_key(key)) do
      raise ArgumentError,
            "child view specification cannot have top-level keys, found: #{inspect(key)}"
    end

    spec
  end

  @resolve_keys ~w(scale axis legend)a

  @doc """
  Adds a resolve entry to the specification.

  Resolution defines how multi-view graphics are combined
  with regard to scales, axis and legend.

  ## Example

      Vl.new()
      |> Vl.data_from_values(...)
      |> Vl.layers([
        Vl.new()
        |> ...,
        Vl.new()
        |> ...
      ])
      |> Vl.resolve(:scale, y: :independent)


  See [the docs](https://vega.github.io/vega-lite/docs/resolve.html) for more details.
  """
  @spec resolve(t(), atom(), keyword()) :: t()
  def resolve(vl, key, opts) do
    validate_inclusion!(@resolve_keys, key, "resolution key")
    validate_at_least_one!(opts, "resolve property")

    for {channel, resolution} <- opts do
      validate_inclusion!(@channels, channel, "resolution channel")
      validate_inclusion!([:shared, :independent], resolution, "resolution type")
    end

    update_in(vl.spec, fn spec ->
      vl_key = to_vl_key(key)
      vl_props = opts_to_vl_props(opts)

      config =
        spec
        |> Map.get("resolve", %{})
        |> Map.put(vl_key, vl_props)

      Map.put(spec, "resolve", config)
    end)
  end

  # Helpers

  defp opts_to_vl_props(opts) do
    opts |> Map.new() |> to_vl()
  end

  defp to_vl(value) when value in [true, false, nil], do: value

  defp to_vl(atom) when is_atom(atom), do: to_vl_key(atom)

  defp to_vl(%_{} = struct), do: struct

  defp to_vl(map) when is_map(map) do
    Map.new(map, fn {key, value} ->
      {to_vl(key), to_vl(value)}
    end)
  end

  defp to_vl([{key, _} | _] = keyword) when is_atom(key) do
    Map.new(keyword, fn {key, value} ->
      {to_vl(key), to_vl(value)}
    end)
  end

  defp to_vl(list) when is_list(list) do
    Enum.map(list, &to_vl/1)
  end

  defp to_vl(value), do: value

  defp to_vl_key(key) when is_atom(key) do
    key |> to_string() |> snake_to_camel()
  end

  defp snake_to_camel(string) do
    [part | parts] = String.split(string, "_")
    Enum.join([String.downcase(part, :ascii) | Enum.map(parts, &String.capitalize(&1, :ascii))])
  end
end