defmodule ElementTui.Components.XYGraph do alias ElementTui.Element alias ElementTui.Components.XYGraph defstruct bounds: nil, data: [] def new(data) do state = %XYGraph{ data: data } extract_bounds(state) end def push(%XYGraph{data: data, bounds: nil} = state, {x, y} = xy) do %XYGraph{state | data: [xy | data], bounds: {x, x, y, y}} end def push(%XYGraph{data: data, bounds: {xmin, xmax, ymin, ymax}} = state, {x, y} = xy) do %XYGraph{ state | data: [xy | data], bounds: {Cmp.min(xmin, x), Cmp.max(xmax, x), Cmp.min(ymin, y), Cmp.max(ymax, y)} } end @doc """ Convert the x, y values to numeric values. This is useful for converting the x, y values to something else, For example, DateTime values need to be converted to a numeric value before plotting (e.g. seconds since epoch). Takes a function that takes a tuple of x, y values and returns a tuple of converted x, y values. """ def convert_xy(state, func) do Map.put(state, :convert_xy, func) end def to_label(state, func) do Map.put(state, :to_label, func) end @doc """ Update/change the bounds on the graph Accepts a function that takes the current bounds (`{xmin, xmax, ymin, ymax}`) and returns the new bounds. """ def update_bounds(state, func) do %{state | bounds: func.(state.bounds)} end def render( %XYGraph{ bounds: {xmin, xmax, ymin, ymax}, data: data } = state, screenx, screeny, screenw, screenh ) do convert_xy = Map.get(state, :convert_xy, fn xy -> xy end) to_label = Map.get(state, :to_label, fn {x, y} -> {"#{x}", "#{y}"} end) # x should be numeric {xmin_f, ymin_f} = convert_xy.({xmin, ymin}) {xmax_f, ymax_f} = convert_xy.({xmax, ymax}) {dx, dy} = {xmax_f - xmin_f, ymax_f - ymin_f} # Axis {xlmin, ylmin} = to_label.({xmin, ymin}) {xlmax, ylmax} = to_label.({xmax, ymax}) {dx, xlmax} = if dx == 0 do # We have no way to know the new value, because xmax and xmax_f are not the same {1, "N/A"} else {dx, xlmax} end {dy, ylmax} = if dy == 0 do {1, "N/A"} else {dy, ylmax} end margin_x = Cmp.max(String.length(ylmin), String.length(ylmax)) + 1 margin_y = 1 Element.text_line(ylmin) |> ElementTui.render(screenx, screeny + screenh - margin_y - 1, margin_x, 1) Element.text_line(ylmax) |> ElementTui.render(screenx, screeny, margin_x, 1) Element.text_line(xlmin) |> ElementTui.render(screenx + margin_x, screeny + screenh - 1, screenw, 1) ln = String.length(xlmax) Element.text_line(xlmax) |> ElementTui.render(screenx + screenw - ln, screeny + screenh - 1, ln, 1) # Data to screen location. The -1 is for corner cases to stop them falling of the edges # Ideally add some tests around this. {dxscreen, dyscreen} = {1.0 * (screenw - margin_x - 1), 1.0 * (screenh - margin_y)} # Scale to screen x, y data |> Enum.map(fn xy -> {x, y} = convert_xy.(xy) {dxscreen * (x - xmin_f) / dx, dyscreen * (y - ymin_f) / dy} end) |> Enum.each(fn {x, y} -> Element.text_line("x") |> ElementTui.render(screenx + margin_x + floor(x), screeny + ceil(dyscreen - y) - 1, 1, 1) end) end defp extract_bounds(%XYGraph{data: []} = state) do %XYGraph{state | bounds: {-1, 1, -1, 1}} end defp extract_bounds(%XYGraph{data: [{x, y} | xys]} = state) do bounds = xys |> Enum.reduce({x, x, y, y}, fn {x, y}, {xmin, xmax, ymin, ymax} -> {Cmp.min(xmin, x), Cmp.max(xmax, x), Cmp.min(ymin, y), Cmp.max(ymax, y)} end) %XYGraph{state | bounds: bounds} end end