lib/archeometer/analysis/treemap/svg_render.ex

defmodule Archeometer.Analysis.Treemap.SVGRender do
  @moduledoc """
  Functions to render an `Archeometer.Analysis.Treemap` struct into a
  [svg](https://www.w3schools.com/graphics/svg_intro.asp) image.

  `Squarified` algorithm by Mark Bruls, Kees Huizing, and Jarke J. van Wijk: https://www.win.tue.nl/~vanwijk/stm.pdf
  """
  require EEx
  alias Archeometer.Analysis.Treemap.Rectangle

  EEx.function_from_file(
    :defp,
    :render_svg,
    "priv/templates/svg/treemap.svg.eex",
    [:rectangles, :ns_sep]
  )

  @doc """
  Renders a `Treemap` struct into a `svg`.

  Returns the string representing the generated `svg` image.

  The resulting `svg` can be written to a file or directly embedded into an `HTML`.

  ## Parameters

  - `root_node`. The `Treemap` struct with `pct: 100` value to be rendered.
  """

  def render(tree, ns_sep \\ ".")

  def render(%{pct: pct}, _) when pct != 100 do
    {:error, "Root node must equal to 100%"}
  end

  def render(%{kind: kind}, _) when kind != :group do
    {:error, "Root node must be a group"}
  end

  def render(%{kind: :group, pct: 100} = root_node, ns_sep) do
    get_frames(root_node, {10, 10}, {0, 0})
    |> List.flatten()
    |> render_svg(ns_sep)
    |> String.replace(~r/\n\s+\n/, "\n")
  end

  defp get_frames(%{kind: :group, nodes: nodes}, size, coords) do
    ordered_nodes = Enum.sort(nodes, &(&1.pct > &2.pct))
    rectangle = squarify(ordered_nodes, [], Rectangle.create_rectangle(size, coords))
    groups = Enum.filter(nodes, &(&1.kind == :group))

    if Enum.empty?(groups) do
      [rectangle]
    else
      groups_names = Enum.map(groups, & &1.name)
      groups_init = Enum.filter(rectangle.areas, &(&1.label in groups_names))

      cleared_rectangle =
        Map.update!(rectangle, :areas, &Enum.filter(&1, fn x -> x not in groups_init end))

      descendant_nodes =
        Enum.map(groups, fn node ->
          node_data = Enum.find(groups_init, &(&1.label == node.name))
          get_frames(node, node_data.size, node_data.begin)
        end)

      if Enum.empty?(cleared_rectangle.areas) do
        descendant_nodes
      else
        [cleared_rectangle | descendant_nodes]
      end
    end
  end

  defp squarify([], row, rectangle) do
    orientation = Rectangle.row_orientation(rectangle)
    Rectangle.layout_row(rectangle, row, orientation)
  end

  defp squarify([child | rest], [], rectangle), do: squarify(rest, [child], rectangle)

  defp squarify([child | rest], row, rectangle) do
    if worst(row, rectangle) > worst([child | row], rectangle) do
      squarify(rest, [child | row], rectangle)
    else
      orientation = Rectangle.row_orientation(rectangle)
      rectangle = Rectangle.layout_row(rectangle, row, orientation)
      squarify([child | rest], [], rectangle)
    end
  end

  defp worst(row, rectangle) do
    weights_list = Enum.map(row, & &1.pct)
    min_r = Enum.min(weights_list)
    max_r = Enum.max(weights_list)
    s = Enum.sum(weights_list)
    w = Rectangle.shortest_drawable_side(rectangle)

    max(
      :math.pow(w, 2) * max_r / :math.pow(s, 2),
      :math.pow(s, 2) / (:math.pow(w, 2) * min_r)
    )
  end
end