diff --git a/lib/mobius/asciichart.ex b/lib/mobius/asciichart.ex index 7399715..fe0597c 100644 --- a/lib/mobius/asciichart.ex +++ b/lib/mobius/asciichart.ex @@ -5,7 +5,7 @@ defmodule Mobius.Asciichart do # ASCII chart generation. # This module was taking from [sndnv's elixir asciichart package](https://github.com/sndnv/asciichart) - # and slightly modified to meet the needs of this project. + # and modified to meet the needs of this project. # Ported to Elixir from [https://github.com/kroitor/asciichart](https://github.com/kroitor/asciichart) @@ -18,8 +18,9 @@ defmodule Mobius.Asciichart do * :padding - one or more characters to use for the label's padding (left) ## Examples + iex> Asciichart.plot([1, 2, 3, 3, 2, 1]) - {:ok, "3.00 ┤ ╭─╮ \n2.00 ┤╭╯ ╰╮ \n1.00 ┼╯ ╰ \n "} + {:ok, "3.00 ┤ ╭─╮ \n2.00 ┤╭╯ ╰╮\n1.00 ┼╯ ╰\n"} # should render as @@ -28,22 +29,22 @@ defmodule Mobius.Asciichart do 1.00 ┼╯ ╰ iex> Asciichart.plot([1, 2, 6, 6, 2, 1], height: 2) - {:ok, "6.00 ┼ \n3.50 ┤ ╭─╮ \n1.00 ┼─╯ ╰─ \n "} + {:ok, "6.00 ┤ ╭─╮ \n3.50 ┤ │ │ \n1.00 ┼─╯ ╰─\n"} # should render as - 6.00 ┼ - 3.50 ┤ ╭─╮ + 6.00 ┤ ╭─╮ + 3.50 ┤ │ │ 1.00 ┼─╯ ╰─ iex> Asciichart.plot([1, 2, 5, 5, 4, 3, 2, 100, 0], height: 3, offset: 10, padding: "__") - {:ok, " 100.00 ┼ ╭╮ \n _50.00 ┤ ││ \n __0.00 ┼──────╯╰ \n "} + {:ok, "100.00 ┤ ╭╮\n_50.00 ┤ ││\n__0.00 ┼──────╯╰\n"} # should render as - 100.00 ┼ ╭╮ - _50.00 ┤ ││ - __0.00 ┼──────╯╰ + 100.00 ┤ ╭╮ + _50.00 ┤ ││ + __0.00 ┼──────╯╰ # Rendering of empty charts is not supported @@ -72,18 +73,8 @@ defmodule Mobius.Asciichart do intmax2 = trunc(max2) rows = abs(intmax2 - intmin2) - width = length(series) + offset - rows_denom = max(1, rows) - # empty space - result = - 0..(rows + 1) - |> Enum.map(fn x -> - {x, 0..width |> Enum.map(fn y -> {y, " "} end) |> Enum.into(%{})} - end) - |> Enum.into(%{}) - max_label_size = (maximum / 1) |> Float.round(2) @@ -98,70 +89,79 @@ defmodule Mobius.Asciichart do label_size = max(min_label_size, max_label_size) - # axis and labels - result = + row_values = intmin2..intmax2 - |> Enum.reduce(result, fn y, map -> + |> Enum.map(fn y -> + (maximum - (y - intmin2) * interval / rows_denom) + |> Float.round(2) + end) + |> Enum.with_index() + + {_, y_inital} = + Enum.min_by(row_values, fn {val, _} -> {abs(Enum.at(series, 0) - val), abs(val)} end) + + zero_axis_row = Enum.find_value(row_values, fn {val, i} -> if(val == 0, do: i) end) + + # axis and labels + labels = + Enum.map(row_values, fn {val, y} -> label = - (maximum - (y - intmin2) * interval / rows_denom) - |> Float.round(2) + val |> :erlang.float_to_binary(decimals: 2) |> String.pad_leading(label_size, padding) + |> String.pad_trailing(offset, " ") - updated_map = put_in(map[y - intmin2][max(offset - String.length(label), 0)], label) - put_in(updated_map[y - intmin2][offset - 1], if(y == 0, do: "┼", else: "┤")) - end) + axis = + cond do + val == 0 -> "┼" + y == y_inital -> "┼" + true -> "┤" + end - # first value - y0 = trunc(Enum.at(series, 0) * ratio - min2) - result = put_in(result[rows - y0][offset - 1], "┼") + "#{label} #{axis}" + end) - # plot the line - result = - 0..(length(series) - 2) - |> Enum.reduce(result, fn x, map -> - y0 = trunc(Enum.at(series, x + 0) * ratio - intmin2) - y1 = trunc(Enum.at(series, x + 1) * ratio - intmin2) - - if y0 == y1 do - put_in(map[rows - y0][x + offset], "─") - else - updated_map = - put_in( - map[rows - y1][x + offset], - if(y0 > y1, do: "╰", else: "╭") - ) - - updated_map = - put_in( - updated_map[rows - y0][x + offset], - if(y0 > y1, do: "╮", else: "╯") - ) - - (min(y0, y1) + 1)..max(y0, y1) - |> Enum.drop(-1) - |> Enum.reduce(updated_map, fn y, map -> - put_in(map[rows - y][x + offset], "│") - end) + data = + series + |> Enum.chunk_every(2, 1, :discard) + |> Enum.with_index() + |> Enum.flat_map(fn {[a, b], x} -> + {_, y0} = Enum.min_by(row_values, fn {val, _} -> {abs(a - val), abs(val)} end) + {_, y1} = Enum.min_by(row_values, fn {val, _} -> {abs(b - val), abs(val)} end) + + cond do + y0 == y1 -> [{{y0, x}, "─"}] + y0 < y1 -> [{{y0, x}, "╮"}, connections(y0, y1, x), {{y1, x}, "╰"}] + y0 > y1 -> [{{y1, x}, "╭"}, connections(y0, y1, x), {{y0, x}, "╯"}] end + |> List.flatten() end) + |> Map.new() - # ensures cell order, regardless of map sizes result = - result - |> Enum.sort_by(fn {k, _} -> k end) - |> Enum.map(fn {_, x} -> - x - |> Enum.sort_by(fn {k, _} -> k end) - |> Enum.map(fn {_, y} -> y end) - |> Enum.join() - end) + for {label, y} <- Enum.with_index(labels) do + row = + for x <- 0..(length(series) - 2), into: "" do + empty = if y == zero_axis_row, do: "┄", else: " " + Map.get(data, {y, x}, empty) + end + + "#{label}#{row}" + end |> Enum.join("\n") - {:ok, result} + {:ok, result <> "\n"} end end + defp connections(y0, y1, x) when abs(y0 - y1) > 1 do + (min(y0, y1) + 1)..max(y0, y1) + |> Enum.drop(-1) + |> Enum.map(fn y -> {{y, x}, "│"} end) + end + + defp connections(_, _, _), do: [] + defp safe_floor(n) when is_integer(n) do n end @@ -177,4 +177,112 @@ defmodule Mobius.Asciichart do defp safe_ceil(n) when is_float(n) do Float.ceil(n) end + + # Ported loosely to Elixir from [https://observablehq.com/@chrispahm/hello-asciichart](https://observablehq.com/@chrispahm/hello-asciichart) + def plot_with_x_axis(y_series, x_series, cfg \\ %{}) + + def plot_with_x_axis(y_series, nil, cfg) do + x_series = for {_, i} <- Enum.with_index(y_series), do: i + plot_with_x_axis(y_series, x_series, cfg) + end + + def plot_with_x_axis(y_series, x_series, cfg) do + case plot(y_series, cfg) do + {:ok, plot} -> + first_line = plot |> String.splitter("\n") |> Enum.at(0) + + full_width = String.length(first_line) + legend_first_line = first_line |> String.splitter(["┤", "┼╮", "┼"]) |> Enum.at(0) + reserved_y_legend_width = String.length(legend_first_line) + 1 + width_x_axis = full_width - reserved_y_legend_width + + longest_x_label = + x_series + |> Enum.map(fn item -> item |> to_shortest_string() |> String.length() end) + |> Enum.max() + + max_decimals = + Keyword.get_lazy(cfg, :decimals, fn -> + x_series + |> Enum.map(fn + float when is_float(float) -> + float + |> to_shortest_string() + |> String.split(".") + |> List.last() + |> String.length() + + _ -> + 0 + end) + |> Enum.max() + end) + + max_no_x_labels = div(width_x_axis, longest_x_label + 2) + 1 + + first_x_value = List.first(x_series) + last_x_value = List.last(x_series) + tick_size = div(width_x_axis, max_no_x_labels - 1) + + ticks = + Stream.repeatedly(fn -> "┬" end) + |> Enum.take(max_no_x_labels) + |> Enum.intersperse(String.duplicate("─", tick_size - 1)) + |> Enum.into("") + |> String.pad_trailing(width_x_axis, "─") + + slope = (last_x_value - first_x_value) / width_x_axis + + labels = + 0 + |> Stream.iterate(&(&1 + tick_size)) + |> Stream.map(fn x -> + Float.round(first_x_value + slope * x, max_decimals) + end) + |> Enum.take(max_no_x_labels) + + legend_padding = String.duplicate(" ", reserved_y_legend_width - 1) + + tick_labels = + labels + |> Enum.reduce(legend_padding <> " ", fn label, acc -> + label = to_shortest_string(label) + relative_to_tick = floor(String.length(label) / 2) + + prev = + case relative_to_tick do + 0 -> + acc + + x when x > 0 -> + {prev, _} = String.split_at(acc, -1 * x) + prev + end + + label = String.pad_trailing(label, tick_size + relative_to_tick, " ") + prev <> label + end) + |> String.trim_trailing() + |> String.pad_trailing(reserved_y_legend_width + width_x_axis, " ") + + tick_string = legend_padding <> "└" <> ticks + + plot = "#{plot}#{tick_string}\n#{tick_labels}\n" + + {:ok, plot} + + error -> + error + end + end + + defp to_shortest_string(float) when is_float(float) do + if Float.floor(float) == float do + float |> trunc() |> Integer.to_string() + else + Float.to_string(float) + end + end + + defp to_shortest_string(int) when is_integer(int), do: Integer.to_string(int) end diff --git a/lib/mobius/exports.ex b/lib/mobius/exports.ex index e67b260..aa88d71 100644 --- a/lib/mobius/exports.ex +++ b/lib/mobius/exports.ex @@ -214,9 +214,28 @@ defmodule Mobius.Exports do end def plot(metric_name, type, tags, opts) do - series = series(metric_name, type, tags, opts) + metrics = get_metrics(metric_name, type, tags, opts) + y_series = Enum.map(metrics, & &1.value) + + max_ts = + with %{timestamp: ts} <- List.last(metrics) do + ts + end + + granularity = + case Keyword.get(opts, :last) do + {_, unit} -> unit + _ -> :second + end + + unit_offset = Mobius.Exports.Metrics.get_unit_offset(granularity) + + x_series = + Enum.map(metrics, fn metric -> + div(metric.timestamp - max_ts, unit_offset) + end) - case Asciichart.plot(series, height: 12) do + case Asciichart.plot_with_x_axis(y_series, x_series, height: 12) do {:ok, plot} -> chart = [ "\t\t", diff --git a/lib/mobius/exports/metrics.ex b/lib/mobius/exports/metrics.ex index c00389f..ae7d515 100644 --- a/lib/mobius/exports/metrics.ex +++ b/lib/mobius/exports/metrics.ex @@ -93,8 +93,8 @@ defmodule Mobius.Exports.Metrics do [from: ts] end - defp get_unit_offset(:second), do: 1 - defp get_unit_offset(:minute), do: 60 - defp get_unit_offset(:hour), do: 3600 - defp get_unit_offset(:day), do: 86400 + def get_unit_offset(:second), do: 1 + def get_unit_offset(:minute), do: 60 + def get_unit_offset(:hour), do: 3600 + def get_unit_offset(:day), do: 86400 end diff --git a/test/mobius/asciichart.exs b/test/mobius/asciichart.exs deleted file mode 100644 index bbc12cc..0000000 --- a/test/mobius/asciichart.exs +++ /dev/null @@ -1,11 +0,0 @@ -defmodule Mobius.AsciichartTest do - use ExUnit.Case, async: false - - alias Mobius.Asciichart - - test "Can generate a chart with nonvarying values" do - # Ensure that we don't blow up when creating a chart with a single row of unvarying values - assert {:ok, plot} = Asciichart.plot([1, 1, 1, 1]) - assert plot == {:ok, "1.00 ┼─── \n "} - end -end diff --git a/test/mobius/asciichart_test.exs b/test/mobius/asciichart_test.exs new file mode 100644 index 0000000..06f789d --- /dev/null +++ b/test/mobius/asciichart_test.exs @@ -0,0 +1,196 @@ +defmodule Mobius.AsciichartTest do + use ExUnit.Case, async: false + + alias Mobius.Asciichart + + doctest Asciichart + + describe "plot/2" do + test "Can generate a chart with nonvarying values" do + # Ensure that we don't blow up when creating a chart with a single row of unvarying values + assert {:ok, plot} = Asciichart.plot([1, 1, 1, 1]) + assert "1.00 ┼───\n" == plot + end + + test "symmetry around 0" do + expected = + [ + " 20.00 ┤ ╭╮ ", + " 16.00 ┤ ││ ", + " 12.00 ┤ ││ ", + " 8.00 ┤╭╯╰╮ ", + " 4.00 ┤│ │ ", + " 0.00 ┼╯┄┄╰╮┄┄╭", + " -4.00 ┤ │ │", + " -8.00 ┤ ╰╮╭╯", + "-12.00 ┤ ││ ", + "-16.00 ┤ ││ ", + "-20.00 ┤ ╰╯ ", + "" + ] + |> Enum.join("\n") + + {:ok, plot} = Asciichart.plot([0, 10, 20, 10, 0, -10, -20, -10, 0], height: 10) + + assert expected == plot + end + + test "use cross for axis place the graph originates from" do + expected = + [ + " 20.00 ┤╭╮ ", + " 16.00 ┤││ ", + " 12.00 ┤││ ", + " 8.00 ┼╯╰╮ ", + " 4.00 ┤ │ ", + " 0.00 ┼┄┄╰╮┄┄╭", + " -4.00 ┤ │ │", + " -8.00 ┤ ╰╮╭╯", + "-12.00 ┤ ││ ", + "-16.00 ┤ ││ ", + "-20.00 ┤ ╰╯ ", + "" + ] + |> Enum.join("\n") + + {:ok, plot} = Asciichart.plot([10, 20, 10, 0, -10, -20, -10, 0], height: 10) + + assert expected == plot + end + end + + describe "x axis" do + test "works" do + expected = + [ + " 20.00 ┤ ╭╮ ╭╮ ", + " 16.00 ┤ ││ ││ ", + " 12.00 ┤ ││ ││ ", + " 8.00 ┤╭╯╰╮ ╭╯╰╮ ", + " 4.00 ┤│ │ │ │ ", + " 0.00 ┼╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭", + " -4.00 ┤ │ │ │ │", + " -8.00 ┤ ╰╮╭╯ ╰╮╭╯", + "-12.00 ┤ ││ ││ ", + "-16.00 ┤ ││ ││ ", + "-20.00 ┤ ╰╯ ╰╯ ", + " └┬───┬───┬───┬───┬", + " 0 4 8 12 16", + "" + ] + |> Enum.join("\n") + + {:ok, plot} = + Asciichart.plot_with_x_axis( + [0, 10, 20, 10, 0, -10, -20, -10, 0, 10, 20, 10, 0, -10, -20, -10, 0], + nil, + height: 10 + ) + + # IO.puts(plot) + + assert expected == plot + end + + test "explicit x series" do + expected = + [ + " 20.00 ┤ ╭╮ ╭╮ ", + " 16.00 ┤ ││ ││ ", + " 12.00 ┤ ││ ││ ", + " 8.00 ┤╭╯╰╮ ╭╯╰╮ ", + " 4.00 ┤│ │ │ │ ", + " 0.00 ┼╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭", + " -4.00 ┤ │ │ │ │", + " -8.00 ┤ ╰╮╭╯ ╰╮╭╯", + "-12.00 ┤ ││ ││ ", + "-16.00 ┤ ││ ││ ", + "-20.00 ┤ ╰╯ ╰╯ ", + " └┬────┬────┬────┬", + " -16 -11 -6 -1", + "" + ] + |> Enum.join("\n") + + {:ok, plot} = + Asciichart.plot_with_x_axis( + [0, 10, 20, 10, 0, -10, -20, -10, 0, 10, 20, 10, 0, -10, -20, -10, 0], + [-16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0], + height: 10 + ) + + # IO.puts(plot) + + assert expected == plot + end + + test "wide x values" do + expected = + [ + " 20.00 ┤ ╭╮ ╭╮ ", + " 16.00 ┤ ││ ││ ", + " 12.00 ┤ ││ ││ ", + " 8.00 ┤╭╯╰╮ ╭╯╰╮ ", + " 4.00 ┤│ │ │ │ ", + " 0.00 ┼╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭", + " -4.00 ┤ │ │ │ │", + " -8.00 ┤ ╰╮╭╯ ╰╮╭╯", + "-12.00 ┤ ││ ││ ", + "-16.00 ┤ ││ ││ ", + "-20.00 ┤ ╰╯ ╰╯ ", + " └┬───────┬───────┬", + " 2000 2009 2017", + "" + ] + |> Enum.join("\n") + + {:ok, plot} = + Asciichart.plot_with_x_axis( + [0, 10, 20, 10, 0, -10, -20, -10, 0, 10, 20, 10, 0, -10, -20, -10, 0], + 2000..2017 |> Enum.to_list(), + height: 10 + ) + + # IO.puts(plot) + + assert expected == plot + end + + test "long chart" do + data = + Enum.flat_map(1..5, fn _ -> + [0, 10, 20, 10, 0, -10, -20, -10, 0, 10, 20, 10, 0, -10, -20, -10] + end) ++ [0] + + expected = + [ + " 20.00 ┤ ╭╮ ╭╮ ╭╮ ╭╮ ╭╮ ╭╮ ╭╮ ╭╮ ╭╮ ╭╮ ", + " 16.00 ┤ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ", + " 12.00 ┤ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ", + " 8.00 ┤╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ╭╯╰╮ ", + " 4.00 ┤│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ", + " 0.00 ┼╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭╯┄┄╰╮┄┄╭", + " -4.00 ┤ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │", + " -8.00 ┤ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯ ╰╮╭╯", + "-12.00 ┤ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ", + "-16.00 ┤ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ", + "-20.00 ┤ ╰╯ ╰╯ ╰╯ ╰╯ ╰╯ ╰╯ ╰╯ ╰╯ ╰╯ ╰╯ ", + " └┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬", + " 0 4 8 12 16 20 24 28 32 36 40 44 48 52 56 60 64 68 72 76 80", + "" + ] + |> Enum.join("\n") + + {:ok, plot} = + Asciichart.plot_with_x_axis( + data, + nil, + height: 10 + ) + + # IO.puts(plot) + + assert expected == plot + end + end +end diff --git a/test/mobius/exports_test.exs b/test/mobius/exports_test.exs index 716fbf3..30168a7 100644 --- a/test/mobius/exports_test.exs +++ b/test/mobius/exports_test.exs @@ -1,6 +1,8 @@ defmodule Mobius.ExportsTest do use ExUnit.Case, async: true + import ExUnit.CaptureIO + alias Mobius.Exports alias Mobius.Exports.MobiusBinaryFormat @@ -41,6 +43,56 @@ defmodule Mobius.ExportsTest do assert File.read!(file) == expected_bin end + @tag :tmp_dir + test "plot", %{tmp_dir: tmp_dir} do + metrics = [ + Telemetry.Metrics.last_value("some.value") + ] + + args = + tmp_dir + |> make_args() + |> Keyword.merge(metrics: metrics) + + {:ok, _} = start_supervised({Mobius, args}) + + # TODO inject via history instead of using up 10s per test + Stream.interval(1000) + |> Stream.map(fn val -> execute_telemetry([:some], %{value: val}) end) + |> Enum.take(10) + + Process.sleep(1000) + + output = + capture_io(fn -> + assert :ok = Exports.plot("some.value", :last_value) + end) + + # IO.puts(output) + + expected = + [ + "9.00 ┤ ╭", + "8.18 ┤ ╭╯", + "7.36 ┤ ╭╯ ", + "6.55 ┤ │ ", + "5.73 ┤ ╭╯ ", + "4.91 ┤ ╭╯ ", + "4.09 ┤ ╭╯ ", + "3.27 ┤ ╭╯ ", + "2.45 ┤ │ ", + "1.64 ┤ ╭╯ ", + "0.82 ┤╭╯ ", + "0.00 ┼╯┄┄┄┄┄┄┄┄", + " └┬───┬───┬", + " -9 -5 -1", + "" + ] + |> Enum.join("\n") + + assert output =~ expected + end + defp make_args(persistence_dir) do [ metrics: [],