lib/motley_hue.ex

defmodule MotleyHue do
  @moduledoc """
  An Elixir utility for calculating the following color combinations:
  * Complimentary - Two colors that are on opposite sides of the color wheel
  * Analagous - Three colors that are side by side on the color wheel
  * Monochromatic - A spectrum of shades, tones and tints of one base color
  * Triadic - Three colors that are evenly spaced on the color wheel
  * Tetradic - Four colors that are evenly spaced on the color wheel
  """

  @doc """
  Returns the provided color and its two analagous (adjacent) colors along a given direction of the HSV color wheel.
  Adjacency is defined by a 30° offset in hue value and an analogous set must reside within a 90° section of the color wheel.

  ## Examples

      iex> MotleyHue.analagous("FF0000")
      ["FF0000", "FF8000", "FFFF00"]

      iex> MotleyHue.analagous("FF0000", :counter_clockwise)
      ["FF0000", "FF0080", "FF00FF"]

  """
  @spec analagous(binary | map, :clockwise | :counter_clockwise) :: list | {:error, binary}
  def analagous(color, direction \\ :clockwise)

  def analagous(color, direction) when direction in [:clockwise, :counter_clockwise] do
    base = Chameleon.convert(color, Chameleon.HSV)

    case base do
      {:error, err} ->
        {:error, err}

      base ->
        1..2
        |> Enum.map(fn i ->
          hue_offset = i * 30

          hue =
            case direction do
              :clockwise ->
                rem(base.h + hue_offset, 360)

              :counter_clockwise ->
                degrees = base.h - hue_offset

                cond do
                  degrees < 0 -> 360 + degrees
                  true -> degrees
                end
            end

          Chameleon.HSV.new(hue, base.s, base.v)
        end)
        |> then(&format_response(color, &1))
    end
  end

  @doc """
  Returns the provided color and its compliment.
  Note that complimentary color can be calculated by either taking the value 180° (i.e., opposite) from the hue value on the HSV color wheel
  or by finding the RGB value that when combined with the provided color will yield white (i.e., rgb(255, 255, 255)).
  The default approach is to use the HSV hue offset, but either can be calculated by passing `:hsv` or `:rgb` as the model argument.

  ## Examples

      iex> MotleyHue.complimentary("FF0000")
      ["FF0000", "00FFFF"]

      iex> MotleyHue.complimentary("008080", :hsv)
      ["008080", "800000"]

      iex> MotleyHue.complimentary("008080", :rgb)
      ["008080", "FF7F7F"]

  """
  @spec complimentary(binary | map, :hsv | :rgb) :: list | {:error, binary}
  def complimentary(color, model \\ :hsv)

  def complimentary(color, :hsv) do
    even(color, 2)
  end

  def complimentary(color, :rgb) do
    base = Chameleon.convert(color, Chameleon.RGB)

    case base do
      {:error, err} ->
        {:error, err}

      base ->
        compliment = Chameleon.RGB.new(255 - base.r, 255 - base.g, 255 - base.b)

        format_response(color, [compliment])
    end
  end

  @doc """
  Returns the requested count of colors, including the provided color, distributed along the color wheel.
  Ideal for use as color palette where adjacent colors need to be easily differentiated with one another (e.g., categorical or other non-quantitative data).

  ## Examples

      iex> MotleyHue.contrast("FF0000", 7)
      ["FF0000", "FFFF00", "00FF00", "00FFFF", "0000FF", "FF00FF", "FF8000"]

      iex> MotleyHue.contrast("FF0000", 13)
      ["FF0000", "FFFF00", "00FF00", "00FFFF", "0000FF", "FF00FF", "FF8000", "80FF00", "00FF80", "0080FF", "8000FF", "FF0080", "FF4000"]

  """
  @spec contrast(binary | map, integer) :: list | {:error, binary}
  def contrast(_color, count) when count < 2,
    do: {:error, "Count must be a positive integer greater than or equal to 2"}

  def contrast(color, count) when count <= 6, do: even(color, count)

  def contrast(color, count) when is_integer(count) do
    base = Chameleon.convert(color, Chameleon.HSV)

    case base do
      {:error, err} ->
        {:error, err}

      base ->
        1..(count - 1)
        |> Enum.map(fn i ->
          div = div(i, 6)

          degree_offset = round(360 / 6)
          base_offset = i * degree_offset
          rotation_offset = -360 * div + safe_divide(degree_offset, 2 * div)
          hue_offset = round(base_offset + rotation_offset)

          hue = rem(base.h + hue_offset, 360)
          Chameleon.HSV.new(hue, base.s, base.v)
        end)
        |> then(&format_response(color, &1))
    end
  end

  @doc """
  Returns the requested count of colors, including the provided color, evenly spaced along the color wheel.

  ## Examples

      iex> MotleyHue.even("FF0000", 5)
      ["FF0000", "CCFF00", "00FF66", "0066FF", "CC00FF"]

  """
  @spec even(binary | map, integer) :: list | {:error, binary}
  def even(_color, count) when count < 2,
    do: {:error, "Count must be a positive integer greater than or equal to 2"}

  def even(color, count) when is_integer(count) do
    base = Chameleon.convert(color, Chameleon.HSV)

    case base do
      {:error, err} ->
        {:error, err}

      base ->
        degree_offset = round(360 / count)

        1..(count - 1)
        |> Enum.map(fn i ->
          hue_offset = i * degree_offset
          hue = rem(base.h + hue_offset, 360)
          Chameleon.HSV.new(hue, base.s, base.v)
        end)
        |> then(&format_response(color, &1))
    end
  end

  @doc """
  Returns the provided color and its monochromatic color spectrum towards black.
  The number of results is configurable with each color equally spaced from the previous value.

  ## Examples

      iex> MotleyHue.monochromatic("FF0000")
      ["FF0000", "AB0000", "570000"]

      iex> MotleyHue.monochromatic("FF0000", 5)
      ["FF0000", "CC0000", "990000", "660000", "330000"]

  """
  @spec monochromatic(binary | map, integer) :: list | {:error, binary}
  def monochromatic(color, count \\ 3)

  def monochromatic(_color, count) when count < 2,
    do: {:error, "Count must be a positive integer greater than or equal to 2"}

  def monochromatic(color, count) when is_integer(count) do
    base = Chameleon.convert(color, Chameleon.HSV)

    case base do
      {:error, err} ->
        {:error, err}

      base ->
        step = div(100, count)

        Range.new(0, 100, step)
        |> Enum.slice(1..(count - 1))
        |> Enum.map(fn value_offset ->
          value = round(base.v - value_offset)
          Chameleon.HSV.new(base.h, base.s, value)
        end)
        |> then(&format_response(color, &1))
    end
  end

  @doc """
  Returns the provided color and its three tetradic colors, which are the colors 90°, 180°, and 270° offset from the given color's hue value on the HSV color wheel.

  ## Examples

      iex> MotleyHue.tetradic("FF0000")
      ["FF0000", "80FF00", "00FFFF", "8000FF"]

  """
  @spec tetradic(binary | map) :: list | {:error, binary}
  def tetradic(color) do
    even(color, 4)
  end

  @doc """
  Returns the provided color and its two triadic colors, which are the colors 120° and 240° offset from the given color's hue value on the HSV color wheel.

  ## Examples

      iex> MotleyHue.triadic("FF0000")
      ["FF0000", "00FF00", "0000FF"]

  """
  @spec triadic(binary | map) :: list | {:error, binary}
  def triadic(color) do
    even(color, 3)
  end

  defp format_response(color, matches) when is_struct(color) do
    [color]
    |> Kernel.++(matches)
    |> Enum.map(&Chameleon.convert(&1, color.__struct__))
  end

  defp format_response(color, matches) when is_binary(color) do
    case Chameleon.Util.derive_input_struct(color) do
      {:ok, %Chameleon.Hex{} = derived_color} ->
        case color do
          "#" <> _ -> derived_color |> format_response(matches) |> Enum.map(&"##{&1.hex}")
          _ -> derived_color |> format_response(matches) |> Enum.map(& &1.hex)
        end

      {:ok, derived_color} ->
        format_response(derived_color, matches)

      {:error, err} ->
        {:error, err}
    end
  end

  defp safe_divide(_, 0), do: 0
  defp safe_divide(num, dem), do: num / dem
end