Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 175 additions & 67 deletions lib/mobius/asciichart.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
23 changes: 21 additions & 2 deletions lib/mobius/exports.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions lib/mobius/exports/metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 0 additions & 11 deletions test/mobius/asciichart.exs

This file was deleted.

Loading