lib/scenic/graph.ex

#
#  Created by Boyd Multerer on 2017-05-06.
#  Copyright © 2017 Kry10 Limited. All rights reserved.
#

defmodule Scenic.Graph do
  @moduledoc """
  Please see [`Graph Overview`](overview_graph.html) for a high-level description.

  ## What is a Graph

  There are many types of graphs in the field of Computer Science. There are graphs that
  show data to a user. There are graphs that give access to databases. Graphs that link
  people to a social network.

  In Scenic, a Graph is a graph in same way that the DOM in HTML is a graph. It is a
  hierarchical tree of data that describes a set of things to draw on the screen.

  You build a graph by appending primitives (individual things to draw) to the current
  node in the tree. Nodes are represented by `Scenic.Primitive.Group`.

  The following example builds a simple graph that displays some text, creates a group,
  then adds more text and a rounded rectangle to it.

  ```elixir
  @graph  Scenic.Graph.build()
  |> text( "This is some text", translate: {20, 20} )
  |> group( fn(graph) ->
    graph
    |> text( "This text is in a group", translate: {200, 24} )
    |> rounded_rectangle( {400, 30}, stroke: {2, :blue})
  end, translate: {20, 100}, text_align: :center)
  ```

  There is a fair amount going on in the example above. The first line builds an empty
  graph with only one group as the root node.

  ```elixir
  @graph Scenic.Graph.build()
  ```

  By assigning it to the
  compile directive `@group`, we know that this group will be built once at compile
  time and will be very fast to access later during runtime. You could also do this
  at runtime, in your init function for example.

  The empty graph that is returned from `build()` is then passed to

  ```elixir
  |> text( "This is some text", translate: {20, 20} )
  ```

  This adds a text primitive to the root group. The graph returned from that call is then
  passed again into

  ```elixir
  |> group( fn(graph) ->
    graph
    |> text( "This text is in a group", translate: {200, 24} )
    |> rounded_rectangle( {400, 30}, stroke: {2, :blue})
  end, translate: {20, 100}, text_align: :center)
  ```

  This creates a new group which is filled with several other primitives.

  Notice that the anonymous group "builder" function receives a graph as its only parameter.
  This is the same graph that we are building, except that it has a marker indicating
  that new primitives added to it are inserted into the new group instead of the
  root of the graph.

  Finally, when the group is finished, a translation matrix and a `:text_align` style
  are added to it. These properties are _inherited_ by the primitives in the group.

  ## Inheritance

  An important concept to understand is that both [styles](overview_styles.html) and
  [transforms](overview_styles.html) are inherited down the graph. This means that if
  you apply a style or transform to any group (including the root), then all primitives
  contained by that group will have those properties applied to them too. This is true
  even if the primitive is nested in several groups at the same time.

  ```elixir
  @graph  Scenic.Graph.build(font: :roboto_mono)
  |> text( "This text inherits the font", translate: {20, 20} )
  |> group( fn(graph) ->
    graph
    |> text( "This text also inherits the font", translate: {200, 24} )
    |> text( "This text overrides the font", font: :roboto )
  end, translate: {20, 100}, text_align: :center)
  ```

  Transforms, such as translate, rotate, scale, also inherit down the graph, but do
  so slightly differently than the styles. With a style, when you set a specific value
  on a primitive, that overrides the inherited value of the same type.

  With a transform, the values multiply together. This allows you to position items
  within a group relative to the origin {0,0}, then move the group as a whole, keeping
  the interior positions unchanged.

  Styles, however, are NOT inherited by components even though transforms are.

  ## Modifying a Graph

  Scenic was written specifically for Erlang/Elixir, which is a functional programming
  model with immutable data.

  As such, once you make a graph, it stays in memory unchanged - until you transform it
  via `Graph.modify/3`. Technically you never change it (that's the immutable part),
  instead Graph.modify returns a new graph with different data in it.

  [Graph.modify/3](Scenic.Graph.html#modify/3) is the single Graph function that you
  will use the most.

  For example, lets go back to our graph with the two text items in it.

  ```elixir
  @graph Graph.build(font: :roboto, font_size: 24, rotate: 0.4)
    |> text("Hello World", translate: {300, 300}, id: :small_text)
    |> text("Bigger Hello", font_size: 40, scale: 1.5, id: :big_text)
  ```

  This time, we've assigned ids to both of the text primitives. This makes it easy to
  find and modify that primitive in the graph.

  ```elixir
  graph =
    @graph
    |> Graph.modify( :small_text, &text(&1, "Smaller Hello", font_size: 16))
    |> Graph.modify( :big_text, &text(&1, "Bigger Hello", font_size: 60))
  ```

  Notice that the graph is modified multiple times in the pipeline. The `push_graph/1`
  function is relatively heavy when the graph references other scenes. The recommended
  pattern is to make multiple changes to the graph .

  ## Accessing Primitives

  When using a Graph, it is extremely common to access and modify primitives. The way
  you do this is by putting an id on the primitives you care about in a graph.

  ```elixir
  @graph Graph.build()
    |> text("small text", id: :small_text)
    |> text("bit text", id: :big_text)
  ```

  When you get primitives, or modify a graph, you specify them by id. This happens
  quickly, but at a cost of using a little memory. If you aren't going to access
  a primitive, then don't assign an id to them.

  One interesting note: There is nothing saying you have to use an atom as the id.
  In fact you can use any Erlang term you want. This can be very powerful, especially
  when used to identify components...
  """

  alias Scenic.Primitive
  alias Scenic.Primitive.Group

  # make reserved uids, 3 or shorter to avoid potential conflicts
  @root_uid 0

  @default_max_depth 128

  @root_id :_root_

  defstruct primitives: %{}, ids: %{}, next_uid: 1, add_to: 0, animations: []

  @type t :: %__MODULE__{
          primitives: map,
          ids: map,
          next_uid: pos_integer,
          add_to: non_neg_integer
        }

  @type bounds :: {left :: number, top :: number, right :: number, bottom :: number}

  @type deferred :: (t -> t)

  # ===========================================================================
  # TODO: define a policy error here - not found or something like that
  defmodule Error do
    @moduledoc false
    defexception message: "Graph was unable to perform the operation",
                 # TODO: expecting more appropriate messages when raising this
                 primitive: nil,
                 style: nil
  end

  @err_msg_depth "Graph too deep. Possible circular reference!"
  @err_msg_depth_option "The :max_depth option must be a positive integer"
  @err_msg_put "Graph.put can only update existing items."
  @err_msg_get_id_one "Graph.get! expected to find one and only one element"

  # ============================================================================
  # build a new graph, starting with the given element
  @doc """
  Builds and returns an empty graph.

  Just like any primitive, you can pass in an option list of styles and transforms.
  These will be applied to the otherwise empty root group in the new graph.
  """
  @spec build(opts :: keyword) :: t()
  def build(opts \\ []) do
    []
    |> Group.build(Keyword.put(opts, :id, @root_id))
    |> new()
    |> set_id(opts[:id])
    |> set_max_depth(opts[:max_depth])
  end

  # ============================================================================
  # add a pre-built primitive
  @doc """
  Add a pre-built primitive to the current group in the graph.

  This is usually called during graph construction. When a new Group primitive
  is added to a Graph, it marks the new group as the current one before calling the group's
  builder function. This is what allows you to add primitives to the correct place
  in the new Group.

  Note: All primitives added to a group are appended to the draw order.
  """
  @spec add(graph :: t(), primitive :: Primitive.t()) :: t()
  def add(graph, primitive)

  def add(%__MODULE__{add_to: puid} = g, %Primitive{} = p) do
    {graph, _uid} = insert_at({g, puid}, -1, p)
    graph
  end

  # build and add new primitives
  @doc """
  Build and add a primitive to the current group in the graph.

  This is usually called during graph construction. When a new Group primitive
  is added to a Graph, it marks the new group as the current one before calling the group's
  builder function. This is what allows you to add primitives to the correct place
  in the new Group.

  Note: All primitives added to a group are appended to the draw order.
  """
  @spec add(graph :: t(), module :: atom, data :: any, opts :: keyword) :: t()
  def add(graph, primitive_module, primitive_data, opts \\ [])

  def add(%__MODULE__{add_to: puid} = g, Group, builder, opts) when is_function(builder, 1) do
    p = Group.build([], opts)
    {graph, uid} = insert_at({g, puid}, -1, p, opts)

    graph
    # set the new group as the add_to target
    |> Map.put(:add_to, uid)
    # call the group builder callback
    |> builder.()
    # restore the add_to to puid
    |> Map.put(:add_to, puid)
  end

  def add(%__MODULE__{add_to: puid} = g, mod, data, opts) when is_atom(mod) do
    p = mod.build(data, opts)
    {graph, _uid} = insert_at({g, puid}, -1, p, opts)
    graph
  end

  # ============================================================================
  # delete a primitive/s from a graph
  @doc """
  Permanently delete a primitive from a group by id.

  This will remove a primitive (or many if they have the same id) from a graph. It
  then returns the modified graph.

  If you delete a group from a graph, then all primitives contained by that
  group are deleted as well.
  """
  @spec delete(graph :: t(), id :: any) :: t()
  def delete(%__MODULE__{primitives: primitives, ids: ids} = graph, id) do
    # resolve the id into a list of uids
    uids = Map.get(ids, id, [])

    # delete each uid. Keep track of the primitives map, and build
    # list of descendands if we're deleting a group
    {primitives, descendants} =
      Enum.reduce(uids, {primitives, []}, fn uid, {prims, removes} ->
        # get the uid of the parent group
        %Primitive{parent_uid: puid, module: mod, data: data} = prims[uid]

        p =
          prims[puid]
          |> remove_reference_from_parent(prims, uid, puid)
          # delete the primitive itself
          |> Map.delete(uid)

        {p, removes ++ children_to_remove(p, mod, data)}
      end)

    # delete the ids
    ids = Map.delete(ids, id)

    # if there was a group involved, remove that group's children
    {primitives, ids} = remove_group_children(descendants, primitives, ids)

    # rebuild an updated graph
    graph
    |> Map.put(:primitives, primitives)
    |> Map.put(:ids, ids)
  end

  # If the thing we're deleting is a group, find all descendants of the
  # group so they can also be deleted.
  defp children_to_remove(prims, Group, children) do
    ([children] ++
       Enum.reduce(children, [], fn child, acc ->
         case prims[child].module do
           Group -> acc ++ children_to_remove(prims, prims[child].module, prims[child].data)
           _ -> acc
         end
       end))
    |> List.flatten()
  end

  defp children_to_remove(_prims, _, _children), do: []

  # Remove all the descendants of the group that was just deleted.
  defp remove_group_children(children, prims, ids) do
    Enum.reduce(children, {prims, ids}, fn child, {prims, ids} ->
      id = Map.get(prims[child], :id, nil)
      {Map.delete(prims, child), Map.delete(ids, id)}
    end)
  end

  # no parent
  defp remove_reference_from_parent(-1, prims, _uid, _puid), do: prims

  defp remove_reference_from_parent(
         %Primitive{module: Group, data: children} = p,
         prims,
         uid,
         puid
       ) do
    children = Enum.reject(children, fn cuid -> cuid == uid end)
    Map.put(prims, puid, %{p | data: children})
  end

  # ============================================================================
  @doc """
  Add to a specified group in a graph.

  Similar to adding a group during graph construction, the `add_to` function accepts
  a builder function that adds to a graph under the identified group.

  Primitives with the `id` that are not groups are ignored.

  If multiple groups have the given `id`, then the builder is run against each of them.
  """
  def add_to(%__MODULE__{primitives: prims} = graph, id, builder) when is_function(builder, 1) do
    # resolve the id into a list of uids
    graph
    |> resolve_id(id)
    |> Enum.reduce(graph, fn uid, g ->
      case prims[uid] do
        %Primitive{module: Group} ->
          g
          |> Map.put(:add_to, uid)
          |> builder.()
          |> case do
            %__MODULE__{} = g -> Map.put(g, :add_to, 0)
            _ -> raise Error, message: "Group.add_to builder must return a valid graph"
          end

        _ ->
          g
      end
    end)
  end

  # ============================================================================
  # put an element by uid - internal use
  defp put_by_uid(graph, uid, primitive)

  defp put_by_uid(%__MODULE__{primitives: primitives} = graph, uid, primitive)
       when is_integer(uid) do
    case get_by_uid(graph, uid) do
      nil ->
        raise Error, message: @err_msg_put

      _ ->
        Map.put(
          graph,
          :primitives,
          Map.put(primitives, uid, primitive)
        )
    end
  end

  # new
  defp new(root) do
    %__MODULE__{
      primitives: %{@root_uid => root},
      # pre-map the root
      ids: %{@root_id => [0]}
    }
  end

  # Set Graph ID
  defp set_id(%__MODULE__{} = graph, nil), do: graph
  defp set_id(%__MODULE__{} = graph, id), do: map_id_to_uid(graph, id, @root_uid)

  # Set Graph max depth
  defp set_max_depth(%__MODULE__{} = graph, nil), do: graph

  defp set_max_depth(%__MODULE__{} = graph, max) when is_integer(max) and max > 0,
    do: Map.put(graph, :max_depth, max)

  defp set_max_depth(_, _), do: raise(Error, message: @err_msg_depth_option)

  # ============================================================================
  # create an entry in the ids
  defp map_id_to_uid(graph, id, uid)

  defp map_id_to_uid(%__MODULE__{ids: ids} = graph, id, uid) when is_integer(uid) do
    Map.put(
      graph,
      :ids,
      do_map_id_to_uid(ids, id, uid)
    )
  end

  defp do_map_id_to_uid(%{} = ids, id, uid) when is_integer(uid) do
    uid_list = Enum.uniq([uid | Map.get(ids, id, [])])
    Map.put(ids, id, uid_list)
  end

  # ============================================================================
  defp resolve_id(graph, id)

  defp resolve_id(%__MODULE__{ids: ids}, id) do
    Map.get(ids, id, [])
  end

  # ============================================================================
  # --------------------------------------------------------
  # count all the nodes in a graph.
  @doc """
  Returns a count of all the primitives in a graph.

  The root Group counts as a primitive, so an empty graph should have a count
  of 1.
  """
  @spec count(graph :: t()) :: integer
  def count(graph)

  def count(%__MODULE__{} = graph) do
    do_reduce(graph, 0, 0, fn _, acc -> acc + 1 end)
  end

  # --------------------------------------------------------
  # count the nodes associated with an id.
  @doc """
  Returns a count of all the primitives in a graph with a specific id.
  """
  @spec count(graph :: t(), id :: any) :: integer
  def count(graph, id)

  def count(%__MODULE__{ids: ids}, id) do
    ids
    |> Map.get(id, [])
    |> Enum.count()
  end

  # ============================================================================
  # get an element by uid. Used internally
  defp get_by_uid(graph, uid, default \\ nil)

  defp get_by_uid(%__MODULE__{primitives: primitives}, uid, default) when is_integer(uid) do
    Map.get(primitives, uid, default)
  end

  # --------------------------------------------------------
  # get an element by uid. Raise error if not there
  defp get_by_uid!(graph, uid)

  defp get_by_uid!(%__MODULE__{primitives: primitives}, uid) do
    Map.fetch!(primitives, uid)
  end

  # ============================================================================
  # get a list of primitives by id
  @doc """
  Returns a list of primitives from a graph with a specific id.
  """
  @spec get(graph :: t(), id :: any) :: list(Primitive.t())
  def get(%__MODULE__{} = graph, id) do
    graph
    |> resolve_id(id)
    |> Enum.reduce([], fn uid, acc -> [get_by_uid(graph, uid) | acc] end)
    |> Enum.reverse()
  end

  # --------------------------------------------------------
  # get a single primitive by id. Raise error if it finds any count other than one
  @doc """
  Returns a single primitive from a graph with a specific id.

  This will raise an error if either none or multiple primitives are found with
  the specified id.
  """
  @spec get!(graph :: t(), id :: any) :: Primitive.t()
  def get!(%__MODULE__{} = graph, id) do
    case resolve_id(graph, id) do
      [uid] -> get_by_uid(graph, uid)
      _ -> raise Error, message: @err_msg_get_id_one
    end
  end

  # ============================================================================
  # insert an element into the graph under the given parent
  # returns the uid and transformed graph with the added element
  # {graph, uid}

  # NOTE: not sure this will stay private. May need to expose if I need to
  # bring back templates

  defp insert_at(graph_and_parent, index, element, opts \\ [])

  # --------------------------------------------------------
  # main version - force it to be a new item via -1 parent id
  defp insert_at(
         {%__MODULE__{primitives: primitives} = graph, parent_uid},
         index,
         %Primitive{} = primitive,
         _opts
       )
       when is_integer(index) do
    # uid for the new item
    uid = next_uid = graph.next_uid

    # prepare the primitive
    primitive = Map.put(primitive, :parent_uid, parent_uid)

    # add the element to the primitives map, setting parent_uid into place
    primitives = Map.put(primitives, uid, primitive)
    graph = Map.put(graph, :primitives, primitives)

    # if a parent is requested, reference the element from the parent at the [sic]right/correct position
    graph =
      case parent_uid do
        -1 ->
          graph

        puid ->
          p_map = graph.primitives

          p_map
          |> Map.get(puid)
          |> Group.insert_at(index, uid)
          |> (&Map.put(p_map, puid, &1)).()
          |> (&Map.put(graph, :primitives, &1)).()
      end

    # if the incoming primitive has an id set on it, map it to the uid
    graph =
      case Map.get(primitive, :id) do
        nil -> graph
        id -> map_id_to_uid(graph, id, uid)
      end

    # increment the next uid and gen the completed graph
    {Map.put(graph, :next_uid, next_uid + 1), uid}
  end

  # ============================================================================

  @doc """
  Find one or more primitives in a graph via a filter function.

  Pass in a function that accepts a primitive and returns a boolean.

  Returns a list of tuples containing the matching id at the primitive.

  `[{id, primitive}]`

  __Warning:__ This function crawls the entire graph and is thus slower than
  accessing items via a fully-specified id.
  """

  @spec find(graph :: t(), (any -> as_boolean(term()))) :: list({any, Primitive.t()})
  def find(graph, finder)

  # pass in an atom based id, and it will transform all mapped uids
  def find(%__MODULE__{} = graph, finder) when is_function(finder, 1) do
    reduce(graph, [], fn p, acc ->
      Map.get(p, :id)
      |> finder.()
      |> case do
        true -> [p | acc]
        false -> acc
      end
    end)
    |> Enum.reverse()
  end

  # --------------------------------------------------------
  # transform a single primitive by uid
  # pass in a list of uids to transform

  defp modify_by_uid(graph, uid, action) when is_integer(uid) and is_function(action, 1) do
    case get_by_uid(graph, uid) do
      # not found - do nothing
      nil ->
        graph

      # transform
      p_original ->
        case action.(p_original) do
          # no change. do nothing
          ^p_original -> graph
          # changed. record it
          %Primitive{} = p_modified -> put_by_uid(graph, uid, p_modified)
          _ -> raise Error, message: "Action must return a valid primitive"
        end
    end
  end

  @doc """
  Modify one or more primitives in a graph.

  Retrieves the primitive (or primitives) specified by id and passes them to
  a callback function. The result of the callback function is stored as the new
  version of that primitive in the graph.

  If multiple primitives match the specified id, then each is passed, in turn,
  to the callback function.

  The id can be either
  * a term to match against (fast)
  * a filter function that returns a boolean (slower)

  Examples:

      graph
      |> Graph.modify( :explicit_id, &text("Updated Text 1") )
      |> Graph.modify( {:id, 123}, &text("Updated Text 2") )
      |> Graph.modify( &match?({:id,_},&1), &text("Updated Text 3") )
  """

  @spec modify(
          graph :: t(),
          id :: any | (any -> as_boolean(term())),
          action :: (any -> Primitive.t())
        ) :: t()
  def modify(graph, id, action)

  # pass in a finder function
  def modify(%__MODULE__{} = graph, finder, action) when is_function(finder, 1) do
    graph
    |> find(finder)
    |> Enum.map(fn %{id: id} -> id end)
    |> Enum.uniq()
    |> Enum.reduce(graph, &modify(&2, &1, action))
  end

  # pass in generic term id
  def modify(%__MODULE__{} = graph, id, action) do
    graph
    |> resolve_id(id)
    |> Enum.reduce(graph, &modify_by_uid(&2, &1, action))
  end

  # ============================================================================
  # map a graph via traversal from the root node
  @doc """
  Map all primitives in a graph into a new graph.

  Crawls through the entire graph, passing each primitive to the callback function.
  The result of the callback replaces that primitive in the graph. The updated
  graph is returned.
  """

  @spec map(graph :: t(), action :: function) :: t()
  def map(%__MODULE__{} = graph, action) when is_function(action, 1) do
    do_map(graph, @root_uid, action)
  end

  # ============================================================================
  @doc """
  Map all primitives in a graph that match a specified id into a new graph.

  Crawls through the entire graph, passing each primitive to the callback function.
  The result of the callback replaces that primitive in the graph. The updated
  graph is returned.

  This is so similar to the modify function that it may be deprecated in the future.
  For now I recommend you use `Graph.modify/3` instead of this.
  """
  @spec map(graph :: t(), id :: any, action :: function) :: t()
  def map(%__MODULE__{} = graph, id, action) when is_function(action, 1) do
    # resolve the id into a list of uids
    uids = resolve_id(graph, id)

    # map those elements via reduction
    Enum.reduce(uids, graph, fn uid, acc ->
      # retrieve and map this node
      acc
      |> get_by_uid!(uid)
      |> action.()
      |> (&put_by_uid(acc, uid, &1)).()
    end)
  end

  # --------------------------------------------------------
  # map a graph via traversal starting at the node named by uid
  defp do_map(graph, uid, action, depth_remaining \\ nil)

  defp do_map(graph, uid, action, nil) do
    max_depth = Map.get(graph, :max_depth, @default_max_depth)
    do_map(graph, uid, action, max_depth)
  end

  defp do_map(_, _, _, 0), do: raise(Error, message: @err_msg_depth)

  defp do_map(graph, uid, action, depth_remaining) do
    # retrieve this node
    primitive = get_by_uid!(graph, uid)

    # retrieve and map this node
    graph =
      primitive
      |> action.()
      |> (&put_by_uid(graph, uid, &1)).()

    # if this is a group, map its children
    case primitive.module do
      Group ->
        # map its children by reducing the graph
        Enum.reduce(Primitive.get(primitive), graph, fn uid, acc ->
          do_map(acc, uid, action, depth_remaining - 1)
        end)

      # do nothing
      _ ->
        graph
    end
  end

  # ============================================================================
  @doc """
  Invokes action for each primitive in the graph with the accumulator.

  Iterates over all primitives in a graph, passing each into the callback function
  with an accumulator. The return value of the callback is the new accumulator.

  This is extremely similar in behaviour to Elixir's Enum.reduce function, except
  that it understands how to navigate the tree structure of a Graph.
  """

  # reduce a graph via traversal from the root node
  @spec reduce(graph :: t(), acc :: any, action :: function) :: any
  def reduce(%__MODULE__{} = graph, acc, action) when is_function(action, 2) do
    do_reduce(graph, @root_uid, acc, action)
  end

  # ============================================================================
  @doc """
  Invokes action for each primitive that matches an id in the graph with the accumulator.

  Iterates over all primitives that match a specified id, passing each into the callback
  function with an accumulator.

  This is extremely similar in behaviour to Elixir's Enum.reduce function, except
  that it understands how to navigate the tree structure of a Graph.
  """
  @spec reduce(graph :: t(), id :: any, acc :: any, action :: function) :: any
  def reduce(%__MODULE__{} = graph, id, acc, action) when is_function(action, 2) do
    # resolve the id into a list of uids
    uids = resolve_id(graph, id)

    # reduce on that list of uids
    Enum.reduce(uids, acc, fn uid, acc ->
      graph
      |> get_by_uid!(uid)
      |> action.(acc)
    end)
  end

  # --------------------------------------------------------
  # do_reduce is where max_depth is honored
  defp do_reduce(graph, uid, acc, action, depth_remaining \\ nil)

  defp do_reduce(graph, uid, acc, action, nil) do
    max_depth = Map.get(graph, :max_depth, @default_max_depth)
    do_reduce(graph, uid, acc, action, max_depth)
  end

  defp do_reduce(_, _, _, _, 0), do: raise(Error, message: @err_msg_depth)

  defp do_reduce(graph, uid, acc, action, depth_remaining) when depth_remaining > 0 do
    # retrieve this node
    primitive = get_by_uid!(graph, uid)

    # if this is a group, reduce its children
    acc =
      case primitive.module do
        Group ->
          Enum.reduce(Primitive.get(primitive), acc, fn uid, acc ->
            do_reduce(graph, uid, acc, action, depth_remaining - 1)
          end)

        # do nothing
        _ ->
          acc
      end

    # reduce the node itself, returns the final accumulator
    action.(primitive, acc)
  end

  # ============================================================================
  @doc false
  @spec style_stack(graph :: t(), uid :: integer) :: map
  def style_stack(%__MODULE__{primitives: p_map} = graph, uid) when is_integer(uid) do
    # get the target primitive
    case p_map[uid] do
      nil ->
        %{}

      %{parent_uid: -1} = p ->
        Primitive.get_styles(p)

      %{parent_uid: puid} = p ->
        # merge the local styles into the parent styles
        graph
        |> style_stack(puid)
        |> Map.merge(Primitive.get_styles(p))
    end
  end

  # ============================================================================
  # --------------------------------------------------------
  @doc """
  Compile a graph into a script.
  """
  @spec compile(graph :: Scenic.Graph.t()) :: {:ok, Scenic.Script.t()}
  defdelegate compile(graph), to: Scenic.Graph.Compiler

  # --------------------------------------------------------
  @doc """
  Compute the bounding box that contains the graph.

  Returns `{left, right, top, bottom}` or `nil` if the graph is empty.
  """
  @spec bounds(graph :: t()) :: Scenic.Graph.bounds() | nil
  defdelegate bounds(graph), to: Scenic.Graph.Bounds, as: :compute
end