defprotocol Contex.PlotContent do @moduledoc """ Defines what a charting component needs to implement to be rendered within a `Contex.Plot` """ @doc """ Generates svg as a string or improper list of strings *without* the SVG containing element. """ def to_svg(plot, options) @doc """ Generates svg content for a legend appropriate for the plot content. """ def get_svg_legend(plot) @doc """ Sets the size for the plot content. This is called after the main layout and margin calculations are performed by the container plot. """ def set_size(plot, width, height) end defmodule Contex.Plot do @moduledoc """ Manages the layout of various plot elements, including titles, axis labels, legends etc and calculates appropriate margins depending on the options set. """ alias __MODULE__ alias Contex.PlotContent defstruct [:title, :subtitle, :x_label, :y_label, :height, :width, :plot_content, :margins, :plot_options] @type plot_text() :: String.t() | nil @default_padding 10 @top_title_margin 20 @top_subtitle_margin 15 @y_axis_margin 20 @y_axis_tick_labels 70 @legend_width 100 @x_axis_margin 20 @x_axis_tick_labels 70 @doc """ Creates a new plot with specified plot content. """ @spec new(integer(), integer(), Contex.PlotContent.t()) :: Contex.Plot.t() def new(width, height, plot_content) do plot_options = %{show_x_axis: true, show_y_axis: true, legend_setting: :legend_none } %Plot{plot_content: plot_content, width: width, height: height, plot_options: plot_options} |> calculate_margins() end @doc """ Updates options for the plot. TODO: There's quite a lot more work to do here. Currently the allowed plot options are `:show_x_axis` (boolean), `:show_y_axis` (boolean), and `:legend_setting` - one of `:legend_none` or `:legend_right`. These are currently passed as a map rather than keyword list. """ #TODO: Allow overriding of margins def plot_options(%Plot{}=plot, new_plot_options) do existing_plot_options = plot.plot_options %{plot | plot_options: Map.merge(existing_plot_options, new_plot_options)} |> calculate_margins() end @doc """ Sets the title and sub-title for the plot. Empty string or nil will remove the title or sub-title """ @spec titles(Contex.Plot.t(), plot_text(), plot_text()) :: Contex.Plot.t() def titles(%Plot{}=plot, title, subtitle) do %{plot | title: title, subtitle: subtitle} |> calculate_margins() end @doc """ Sets the x-axis & y-axis labels for the plot. Empty string or nil will remove them. """ @spec axis_labels(Contex.Plot.t(), plot_text(), plot_text()) :: Contex.Plot.t() def axis_labels(%Plot{}=plot, x_label, y_label) do %{plot | x_label: x_label, y_label: y_label} |> calculate_margins() end @doc """ Updates the size for the plot """ @spec size(Contex.Plot.t(), integer(), integer()) :: Contex.Plot.t() def size(%Plot{}=plot, width, height) do %{plot | width: width, height: height} |> calculate_margins() end @doc """ Generates SVG output marked as safe for the configured plot. """ def to_svg(%Plot{width: width, height: height, plot_content: plot_content}=plot) do %{left: left, right: right, top: top, bottom: bottom} = plot.margins content_height = height - (top + bottom) content_width = width - (left + right) # TODO: Legend calcs need to be redone if it can be displayed at the top legend_left = left + content_width + @default_padding legend_top = top + @default_padding plot_content = PlotContent.set_size(plot_content, content_width, content_height) output = [~s||, get_titles_svg(plot, content_width), get_axis_labels_svg(plot, content_width, content_height), ~s||, PlotContent.to_svg(plot_content, plot.plot_options), "", get_svg_legend(plot_content, legend_left, legend_top, plot.plot_options), "" ] {:safe, output} end defp get_svg_legend(plot_content, legend_left, legend_top, %{legend_setting: :legend_right}) do [~s||, PlotContent.get_svg_legend(plot_content), "" ] end defp get_svg_legend(_plot_content, _legend_left, _legend_top, _opts), do: "" defp get_titles_svg(%Plot{title: title, subtitle: subtitle, margins: margins}=_plot, content_width) when is_binary(title) or is_binary(subtitle) do centre = margins.left + (content_width / 2.0) title_y = @top_title_margin title_svg = case is_non_empty_string(title) do true -> ~s|#{title}| _ -> "" end subtitle_y = case is_non_empty_string(title) do true -> @top_subtitle_margin + @top_title_margin _ -> @top_subtitle_margin end subtitle_svg = case is_non_empty_string(subtitle) do true -> ~s|#{subtitle}| _ -> "" end [title_svg, subtitle_svg] end defp get_titles_svg(_, _), do: "" defp get_axis_labels_svg(%Plot{x_label: x_label, y_label: y_label, margins: margins}=_plot, content_width, content_height) when is_binary(x_label) or is_binary(y_label) do x_label_x = margins.left + (content_width / 2.0) x_label_y = margins.top + content_height + @x_axis_tick_labels y_label_x = -1.0 * (margins.top + (content_height / 2.0)) # -90 rotation screws with coordinates y_label_y = @y_axis_margin x_label_svg = case is_non_empty_string(x_label) do true -> ~s|#{x_label}| _ -> "" end y_label_svg = case is_non_empty_string(y_label) do true -> ~s|#{y_label}| false -> "" end [x_label_svg, y_label_svg] end defp get_axis_labels_svg(_, _, _), do: "" defp calculate_margins(%Plot{}=plot) do left = calculate_left_margin(plot) top = calculate_top_margin(plot) right = calculate_right_margin(plot) bottom = calculate_bottom_margin(plot) margins = %{left: left, top: top, right: right, bottom: bottom} %{plot | margins: margins} end defp calculate_left_margin(%Plot{}=plot) do margin = 0 margin = margin + if plot.plot_options.show_y_axis, do: @y_axis_tick_labels, else: 0 margin = margin + if is_non_empty_string(plot.y_label), do: @y_axis_margin, else: 0 margin end defp calculate_right_margin(%Plot{}=plot) do margin = @default_padding margin = margin + if (plot.plot_options.legend_setting == :legend_right), do: @legend_width, else: 0 margin end defp calculate_bottom_margin(%Plot{}=plot) do margin = 0 margin = margin + if plot.plot_options.show_x_axis, do: @x_axis_tick_labels, else: 0 margin = margin + if is_non_empty_string(plot.x_label), do: @x_axis_margin, else: 0 margin end defp calculate_top_margin(%Plot{}=plot) do margin = @default_padding margin = margin + if is_non_empty_string(plot.title), do: @top_title_margin + @default_padding, else: 0 margin = margin + if is_non_empty_string(plot.subtitle), do: @top_subtitle_margin, else: 0 margin end defp is_non_empty_string(val) when is_nil(val), do: false defp is_non_empty_string(val) when val == "", do: false defp is_non_empty_string(val) when is_binary(val), do: true defp is_non_empty_string(_), do: false end #TODO: Probably move to appropriate module files... defimpl Contex.PlotContent, for: Contex.BarChart do def to_svg(plot, options), do: Contex.BarChart.to_svg(plot, options) def get_svg_legend(plot), do: Contex.BarChart.get_svg_legend(plot) def set_size(plot, width, height), do: Contex.BarChart.set_size(plot, width, height) end defimpl Contex.PlotContent, for: Contex.PointPlot do def to_svg(plot, _options), do: Contex.PointPlot.to_svg(plot) def get_svg_legend(plot), do: Contex.PointPlot.get_svg_legend(plot) def set_size(plot, width, height), do: Contex.PointPlot.set_size(plot, width, height) end defimpl Contex.PlotContent, for: Contex.GanttChart do def to_svg(plot, options), do: Contex.GanttChart.to_svg(plot, options) def get_svg_legend(_plot), do: "" #Contex.PointPlot.get_legend_svg(plot) def set_size(plot, width, height), do: Contex.GanttChart.set_size(plot, width, height) end