import enum
from typing import Callable
from .textcanvas import TextCanvas
class PlotType(enum.Enum):
LINE = "LINE"
SCATTER = "SCATTER"
class Plot:
"""Helper functions to plot data on a `TextCanvas`.
`Plot` does nothing magical. Calling functions on `Plot` is
exactly like drawing manually on the canvas. This entails that
nothing changes in the way you use the canvas before or after
plotting. Nor does it change the way you apply colors.
There are two classes of functions in `Plot`:
- Functions that take a discrete set of values as input.
- Functions that take a function as input (they all have `function`
in their name).
The main difference is that for those that take a discrete set as
input, `Plot` does nothing in particular. But for those that take a
function as input, `Plot` will be able to compute any value it needs
to plot the function with the highest precision possible.
Note:
All the helper functions auto-scale the input data. The purpose
of this is to have a _quick_ and _simple_ way to graph things
out.
Auto-scaling in this context means the lowest X value will be
plotted on the left border of the canvas, and the highest X
value will be plotted on the right side of the canvas, and all
the values in-between will be distributed uniformly. Same for Y.
If you absolutely need the plot to be smaller than the canvas,
you need to plot it to a _different_ canvas that has the target
size, and then draw the smaller canvas with the graph onto the
parent canvas. Use `draw_canvas()` or `merge_canvas()` from
`TextCanvas` to do this easily.
"""
@staticmethod
def stroke_xy_axes(canvas: TextCanvas, x: list[float], y: list[float]) -> None:
"""Stroke X and Y axes.
If 0 is not visible on an axis, the axis will not be drawn.
`x` and `y` _should_ match in length,
If `x` and `y` are not the same length, plotting will stop once
the smallest of the two collections is consumed.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> x: list[float] = list(range(-5, 6))
>>> y: list[float] = list(range(-5, 6))
>>> Plot.stroke_xy_axes(canvas, x, y)
>>> Plot.line(canvas, x, y)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⢀⠤⠒⠉
⠀⠀⠀⠀⠀⠀⠀⡇⢀⠤⠊⠁⠀⠀⠀
⠤⠤⠤⠤⠤⢤⠤⡯⠥⠤⠤⠤⠤⠤⠤
⠀⠀⢀⠤⠊⠁⠀⡇⠀⠀⠀⠀⠀⠀⠀
⡠⠊⠁⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀
"""
Plot.stroke_x_axis(canvas, y)
Plot.stroke_y_axis(canvas, x)
@staticmethod
def stroke_x_axis(canvas: TextCanvas, y: list[float]) -> None:
"""Stroke X axis.
See `stroke_xy_axes()` which has the same API for an example.
Args:
y: Values of the Y axis, used to determine where Y = 0 is.
"""
Plot.stroke_line_at_y(canvas, 0.0, y)
@staticmethod
def stroke_y_axis(canvas: TextCanvas, x: list[float]) -> None:
"""Stroke Y axis.
See `stroke_xy_axes()` which has the same API for an example.
Args:
x: Values of the X axis, used to determine where X = 0 is.
"""
Plot.stroke_line_at_x(canvas, 0.0, x)
@staticmethod
def stroke_line_at_x(canvas: TextCanvas, value: float, x: list[float]) -> None:
"""Stroke vertical line at X = value.
If the value is out of the range of Y values, nothing will be
drawn.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> x: list[float] = list(range(-5, 6))
>>> Plot.stroke_line_at_x(canvas, -5.0, x)
>>> Plot.stroke_line_at_x(canvas, -2.5, x)
>>> Plot.stroke_line_at_x(canvas, 0.0, x)
>>> Plot.stroke_line_at_x(canvas, 2.5, x)
>>> Plot.stroke_line_at_x(canvas, 5.0, x)
>>> print(canvas, end="")
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
"""
if (screen_x := Plot.compute_screen_x(canvas, value, x)) is None:
return
canvas.stroke_line(screen_x, 0, screen_x, canvas.h)
@staticmethod
def stroke_line_at_y(canvas: TextCanvas, value: float, y: list[float]) -> None:
"""Stroke horizontal line at Y = value.
If the value is out of the range of Y values, nothing will be
drawn.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> y: list[float] = list(range(-5, 6))
>>> Plot.stroke_line_at_y(canvas, -5.0, y)
>>> Plot.stroke_line_at_y(canvas, -2.5, y)
>>> Plot.stroke_line_at_y(canvas, 0.0, y)
>>> Plot.stroke_line_at_y(canvas, 2.5, y)
>>> Plot.stroke_line_at_y(canvas, 5.0, y)
>>> print(canvas, end="")
⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉
⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒
⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤
⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀
⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀
"""
if (screen_y := Plot.compute_screen_y(canvas, value, y)) is None:
return
canvas.stroke_line(0, screen_y, canvas.w, screen_y)
@staticmethod
def compute_screen_x(
canvas: TextCanvas, value: float, x: list[float]
) -> int | None:
"""Compute X position of a value on the canvas.
Remember, values are auto-scaled to fit the canvas. If X goes
from _-10_ to _10_, then:
- Screen X of _-10_ will be 0
- Screen X of _10_ will be canvas width
- Screen X of _0_ will be canvas center X
Examples:
>>> canvas = TextCanvas(15, 5)
>>> x: list[float] = list(range(-10, 11))
>>> assert 0 == Plot.compute_screen_x(canvas, -10.0, x)
>>> assert 29 == Plot.compute_screen_x(canvas, 10.0, x)
>>> assert 14 == Plot.compute_screen_x(canvas, 0.0, x)
"""
if not x:
return None
min_x: float = min(x)
max_x: float = max(x)
range_x: float = max_x - min_x
try:
scale_x: float = canvas.w / range_x
except ZeroDivisionError:
return canvas.cx
# Shift data left, so that `min_x` would = 0, then scale so
# that `max_x` would = width.
return int((value - min_x) * scale_x)
@staticmethod
def compute_screen_y(
canvas: TextCanvas, value: float, y: list[float]
) -> int | None:
"""Compute Y position of a value on the canvas.
Remember, values are auto-scaled to fit the canvas. If Y goes
from _-10_ to _10_, then:
- Screen X of _-10_ will be canvas height
- Screen X of _10_ will be 0
- Screen X of _0_ will be canvas center Y
Examples:
>>> canvas = TextCanvas(15, 5)
>>> y: list[float] = list(range(-10, 11))
>>> assert 19 == Plot.compute_screen_y(canvas, -10.0, y)
>>> assert 0 == Plot.compute_screen_y(canvas, 10.0, y)
>>> assert 10 == Plot.compute_screen_y(canvas, 0.0, y)
"""
if not y:
return None
min_y: float = min(y)
max_y: float = max(y)
range_y: float = max_y - min_y
try:
scale_y: float = canvas.h / range_y
except ZeroDivisionError:
return canvas.cy
# Shift data down, so that `min_y` would = 0, then scale so
# that `max_y` would = height.
return canvas.h - int((value - min_y) * scale_y) # Y-axis is inverted.
@staticmethod
def stroke_xy_axes_of_function(
canvas: TextCanvas,
from_x: float,
to_x: float,
f: Callable[[float], float],
) -> None:
"""Stroke X and Y axes, given a function.
The function is scaled to take up the entire canvas. The axes
are then placed where _X_ and _Y_ = _0_;
If 0 is not visible on an axis, the axis will not be drawn.
Examples:
>>> import math
>>> canvas = TextCanvas(15, 5)
>>> f = lambda x: math.sin(x)
>>> Plot.stroke_xy_axes_of_function(canvas, -3.0, 7.0, f)
>>> Plot.function(canvas, -3.0, 7.0, f)
>>> print(canvas, end="")
⠀⠀⠀⠀⡇⢠⠋⠑⡄⠀⠀⠀⠀⠀⢀
⠀⠀⠀⠀⣇⠇⠀⠀⢱⠀⠀⠀⠀⠀⡎
⡤⠤⠤⠤⡿⠤⠤⠤⠤⡧⠤⠤⠤⡼⠤
⠸⡀⠀⢰⡇⠀⠀⠀⠀⠸⡀⠀⢠⠃⠀
⠀⠱⡠⠃⡇⠀⠀⠀⠀⠀⠑⠤⠊⠀⠀
"""
# `stroke_(x|y)_axis_of_function()` methods would both compute
# the values of `f()`. It is more efficient to compute these
# values once, and use the regular `stroke_(x|y)_axis()`
# methods instead.
nb_values: int = canvas.screen.width
(x, y) = Plot.compute_function(from_x, to_x, nb_values, f)
Plot.stroke_x_axis(canvas, y)
Plot.stroke_y_axis(canvas, x)
@staticmethod
def stroke_x_axis_of_function(
canvas: TextCanvas,
from_x: float,
to_x: float,
f: Callable[[float], float],
) -> None:
"""Stroke X axis, given a function.
See `stroke_xy_axes_of_function()` which has the same API for an
example.
"""
Plot.stroke_line_at_y_of_function(canvas, 0.0, from_x, to_x, f)
@staticmethod
def stroke_y_axis_of_function(
canvas: TextCanvas,
from_x: float,
to_x: float,
f: Callable[[float], float],
) -> None:
"""Stroke Y axis, given a function.
See `stroke_xy_axes_of_function()` which has the same API for an
example.
"""
Plot.stroke_line_at_x_of_function(canvas, 0.0, from_x, to_x, f)
@staticmethod
def stroke_line_at_x_of_function(
canvas: TextCanvas,
value: float,
from_x: float,
to_x: float,
f: Callable[[float], float],
) -> None:
"""Stroke vertical line at X = value, given a function.
Same as `stroke_line_at_x()`, but for a function.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> f = lambda x: x
>>> Plot.stroke_line_at_x_of_function(canvas, -5.0, -5.0, 5.0, f)
>>> Plot.stroke_line_at_x_of_function(canvas, -2.5, -5.0, 5.0, f)
>>> Plot.stroke_line_at_x_of_function(canvas, 0.0, -5.0, 5.0, f)
>>> Plot.stroke_line_at_x_of_function(canvas, 2.5, -5.0, 5.0, f)
>>> Plot.stroke_line_at_x_of_function(canvas, 5.0, -5.0, 5.0, f)
>>> print(canvas, end="")
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
⡇⠀⠀⢸⠀⠀⠀⡇⠀⠀⢸⠀⠀⠀⢸
"""
nb_values: int = canvas.screen.width
(x, _) = Plot.compute_function(from_x, to_x, nb_values, f)
Plot.stroke_line_at_x(canvas, value, x)
@staticmethod
def stroke_line_at_y_of_function(
canvas: TextCanvas,
value: float,
from_x: float,
to_x: float,
f: Callable[[float], float],
) -> None:
"""Stroke horizontal line at Y = value, given a function.
Same as `stroke_line_at_y()`, but for a function.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> f = lambda x: x
>>> Plot.stroke_line_at_y_of_function(canvas, -5.0, -5.0, 5.0, f)
>>> Plot.stroke_line_at_y_of_function(canvas, -2.5, -5.0, 5.0, f)
>>> Plot.stroke_line_at_y_of_function(canvas, 0.0, -5.0, 5.0, f)
>>> Plot.stroke_line_at_y_of_function(canvas, 2.5, -5.0, 5.0, f)
>>> Plot.stroke_line_at_y_of_function(canvas, 5.0, -5.0, 5.0, f)
>>> print(canvas, end="")
⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉
⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒
⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤
⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀
⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀
"""
nb_values: int = canvas.screen.width
(_, y) = Plot.compute_function(from_x, to_x, nb_values, f)
Plot.stroke_line_at_y(canvas, value, y)
@staticmethod
def compute_screen_x_of_function(
canvas: TextCanvas,
value: float,
from_x: float,
to_x: float,
f: Callable[[float], float],
) -> int | None:
"""Compute X position of a value on the canvas, given a
function.
Same as `compute_screen_x()`, but for a function.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> f = lambda x: x
>>> assert 0 == Plot.compute_screen_x_of_function(canvas, -10.0, -10.0, 10.0, f)
>>> assert 14 == Plot.compute_screen_x_of_function(canvas, 0.0, -10.0, 10.0, f)
>>> assert 29 == Plot.compute_screen_x_of_function(canvas, 10.0, -10.0, 10.0, f)
"""
nb_values: int = canvas.screen.width
(x, _) = Plot.compute_function(from_x, to_x, nb_values, f)
return Plot.compute_screen_x(canvas, value, x)
@staticmethod
def compute_screen_y_of_function(
canvas: TextCanvas,
value: float,
from_x: float,
to_x: float,
f: Callable[[float], float],
) -> int | None:
"""Compute Y position of a value on the canvas, given a
function.
Same as `compute_screen_y()`, but for a function.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> f = lambda x: x
>>> assert 19 == Plot.compute_screen_y_of_function(canvas, -10.0, -10.0, 10.0, f)
>>> assert 10 == Plot.compute_screen_y_of_function(canvas, 0.0, -10.0, 10.0, f)
>>> assert 0 == Plot.compute_screen_y_of_function(canvas, 10.0, -10.0, 10.0, f)
"""
nb_values: int = canvas.screen.width
(_, y) = Plot.compute_function(from_x, to_x, nb_values, f)
return Plot.compute_screen_y(canvas, value, y)
@staticmethod
def line(canvas: TextCanvas, x: list[float], y: list[float]) -> None:
"""Plot line-joined points.
The data is scaled to take up the entire canvas.
`x` and `y` _should_ match in length,
If `x` and `y` are not the same length, plotting will stop once
the smallest of the two collections is consumed.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> x: list[float] = list(range(-5, 6))
>>> y: list[float] = list(range(-5, 6))
>>> Plot.line(canvas, x, y)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠒⠉
⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠁⠀⠀⠀
⠀⠀⠀⠀⠀⢀⠤⠊⠁⠀⠀⠀⠀⠀⠀
⠀⠀⢀⠤⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
⡠⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
"""
Plot._plot(canvas, x, y, PlotType.LINE)
@staticmethod
def scatter(canvas: TextCanvas, x: list[float], y: list[float]) -> None:
"""Plot scattered points.
The data is scaled to take up the entire canvas.
`x` and `y` _should_ match in length,
If `x` and `y` are not the same length, plotting will stop once
the smallest of the two collections is consumed.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> x: list[float] = list(range(-5, 6))
>>> y: list[float] = list(range(-5, 6))
>>> Plot.scatter(canvas, x, y)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⠂⠈
⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⠂⠀⠀⠀⠀
⠀⠀⠀⠀⠀⢀⠀⠂⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢀⠀⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⡀⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
"""
Plot._plot(canvas, x, y, PlotType.SCATTER)
@staticmethod
def _plot(
canvas: TextCanvas,
x_vals: list[float],
y_vals: list[float],
plot_type: PlotType,
) -> None:
if not x_vals or not y_vals:
return
pairs: list[tuple[float, float]] = list(zip(x_vals, y_vals))
if plot_type == PlotType.LINE:
# Sort by `x`
pairs.sort(key=lambda pair: pair[0])
min_x: float = min(x_vals)
max_x: float = max(x_vals)
range_x: float = max_x - min_x
scale_x_is_infinite: bool = False
try:
scale_x: float = canvas.w / range_x
except ZeroDivisionError:
scale_x_is_infinite = True
scale_x = float("+inf")
min_y: float = min(y_vals)
max_y: float = max(y_vals)
range_y: float = max_y - min_y
scale_y_is_infinite: bool = False
try:
scale_y: float = canvas.h / range_y
except ZeroDivisionError:
scale_y_is_infinite = True
scale_y = float("+inf")
if scale_x_is_infinite or scale_y_is_infinite:
# One or both axis have no range. This doesn't make sense
# for plotting with auto-scale.
return Plot._handle_axes_without_range(
canvas,
x_vals,
y_vals,
plot_type,
scale_x_is_infinite,
scale_y_is_infinite,
)
previous: tuple[int, int] | None = None # For line plot.
for x, y in pairs:
# Shift data left so that `min_x` = 0, then scale so that
# `max_x` = width.
x = (x - min_x) * scale_x
x = int(x)
y = (y - min_y) * scale_y
y = canvas.h - y # Y-axis is inverted.
y = int(y)
match plot_type:
case PlotType.LINE:
pair = (x, y)
if previous is not None:
canvas.stroke_line(previous[0], previous[1], pair[0], pair[1])
previous = pair
case PlotType.SCATTER:
canvas.set_pixel(x, y, True)
@staticmethod
def _handle_axes_without_range(
canvas: TextCanvas,
x_vals: list[float],
y_vals: list[float],
plot_type: PlotType,
x_has_no_range: bool,
y_has_no_range: bool,
) -> None:
x_has_range_but_not_y: bool = not x_has_no_range and y_has_no_range
y_has_range_but_not_x: bool = x_has_no_range and not y_has_no_range
both_have_no_range: bool = x_has_no_range and y_has_no_range
if x_has_range_but_not_y:
# Y is a constant, draw a single centered line.
Plot._draw_horizontally_centered_line(canvas, x_vals, plot_type)
elif y_has_range_but_not_x:
# Compress all Ys into a single centered line.
Plot._draw_vertically_centered_line(canvas, y_vals, plot_type)
elif both_have_no_range:
# Draw a dot in the middle to show the user we tried to do
# something, but the values are off.
canvas.set_pixel(canvas.cx, canvas.cy, True)
@staticmethod
def _draw_horizontally_centered_line(
canvas: TextCanvas, x_vals: list[float], plot_type: PlotType
) -> None:
match plot_type:
case PlotType.LINE:
canvas.stroke_line(0, canvas.cy, canvas.w, canvas.cy)
case PlotType.SCATTER:
for x_val in x_vals:
if (x := Plot.compute_screen_x(canvas, x_val, x_vals)) is not None:
canvas.set_pixel(x, canvas.cy, True)
@staticmethod
def _draw_vertically_centered_line(
canvas: TextCanvas, y_vals: list[float], plot_type: PlotType
) -> None:
match plot_type:
case PlotType.LINE:
canvas.stroke_line(canvas.cx, 0, canvas.cx, canvas.h)
case PlotType.SCATTER:
for y_val in y_vals:
if (y := Plot.compute_screen_y(canvas, y_val, y_vals)) is not None:
canvas.set_pixel(canvas.cx, y, True)
@staticmethod
def function(
canvas: TextCanvas, from_x: float, to_x: float, f: Callable[[float], float]
) -> None:
"""Plot a function.
The function is scaled to take up the entire canvas, and is
assumed to be continuous (points will be line-joined together).
Examples:
>>> canvas = TextCanvas(15, 5)
>>> Plot.function(canvas, -10.0, 10.0, lambda x: x ** 2)
>>> print(canvas, end="")
⠱⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜
⠀⢣⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀
⠀⠀⠣⡀⠀⠀⠀⠀⠀⠀⠀⠀⡔⠁⠀
⠀⠀⠀⠑⡄⠀⠀⠀⠀⠀⢀⠎⠀⠀⠀
⠀⠀⠀⠀⠈⠒⠤⣀⠤⠒⠁⠀⠀⠀⠀
"""
nb_values: int = canvas.screen.width
(x, y) = Plot.compute_function(from_x, to_x, nb_values, f)
Plot.line(canvas, x, y)
@staticmethod
def compute_function[T](
from_x: float,
to_x: float,
nb_values: float,
f: Callable[[float], T],
) -> tuple[list[float], list[T]]:
"""Compute the values of a function.
This is mainly used internally to compute values for functions.
However, it may also be useful in case one wants to pre-compute
values.
Note:
The return value of the function is generic. You can use
`compute_function()` to compute anything, but if the values
of Y are not `float`s, you will need to adapt them before
use.
This is useful for optimisation. Say you have an expensive
function that returns a `dataclass` with multiple fields. If
only `float`s were allowed, you would have to re-compute the
exact same function for each field of the struct. But thanks
to the generic return type, you can compute the function
_once_, and extract the fields into separate lists by
mapping the values.
Examples:
>>> import math
>>> canvas = TextCanvas(15, 5)
>>> canvas2 = TextCanvas(15, 5)
>>> f = lambda x: math.sin(x)
>>> # This is inefficient, because `f()` will be computed twice.
>>> Plot.stroke_xy_axes_of_function(canvas, -3.0, 7.0, f)
>>> Plot.function(canvas, -3.0, 7.0, f)
>>> # This is better, the values are computed only once.
>>> (x, y) = Plot.compute_function(-3.0, 7.0, canvas2.screen.width, f)
>>> Plot.stroke_xy_axes(canvas2, x, y)
>>> Plot.line(canvas2, x, y)
>>> assert canvas.to_string() == canvas2.to_string()
Note that the "inefficient" solution is unlikely to cause a
noticeable performance hit. The simpler approach is most often
the better approach.
"""
range: float = to_x - from_x
# If we want 5 values in a range including bounds, we need to
# divide the range into 4 equal pieces:
# 1 2 3 4
# | | | | |
# 1 2 3 4 5
step: float = range / (nb_values - 1)
px: list[float] = []
py: list[T] = []
# Always add first value.
px.append(from_x)
py.append(f(from_x))
x = from_x + step
while x < to_x:
px.append(x)
py.append(f(x))
x += step
# Always add last value.
px.append(to_x)
py.append(f(to_x))
return px, py
class Chart:
"""Helper functions to render charts on a `TextCanvas`.
Basically, this renders a `Plot` and makes it pretty.
The idea comes from .
"""
MARGIN_TOP: int = 1
MARGIN_RIGHT: int = 2
MARGIN_BOTTOM: int = 2
MARGIN_LEFT: int = 10
HORIZONTAL_MARGIN: int = MARGIN_LEFT + MARGIN_RIGHT
VERTICAL_MARGIN: int = MARGIN_TOP + MARGIN_BOTTOM
@staticmethod
def line(canvas: TextCanvas, x: list[float], y: list[float]) -> None:
"""Render chart with a line plot.
Examples:
>>> canvas = TextCanvas(35, 10)
>>> x: list[float] = list(range(-5, 6))
>>> y: list[float] = list(range(-5, 6))
>>> Chart.line(canvas, x, y)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀5⠀⡤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠒⠉⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠊⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡠⠒⠉⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⢀⡠⠔⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⢀⡠⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⡠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀-5⠀⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠚⠀
⠀⠀⠀⠀⠀⠀⠀⠀-5⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀5
Raises:
ValueError: If chart is < 13×4, because it would make plot
size < 1×1.
"""
Chart._chart(canvas, x, y, PlotType.LINE)
@staticmethod
def scatter(canvas: TextCanvas, x: list[float], y: list[float]) -> None:
"""Render chart with a scatter plot.
Examples:
>>> canvas = TextCanvas(35, 10)
>>> x: list[float] = list(range(-5, 6))
>>> y: list[float] = list(range(-5, 6))
>>> Chart.scatter(canvas, x, y)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀5⠀⡤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⠈⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠀⠈⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠠⠀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⡀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀-5⠀⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠚⠀
⠀⠀⠀⠀⠀⠀⠀⠀-5⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀5
Raises:
ValueError: If chart is < 13×4, because it would make plot
size < 1×1.
"""
Chart._chart(canvas, x, y, PlotType.SCATTER)
@staticmethod
def _chart(
canvas: TextCanvas, x: list[float], y: list[float], plot_type: PlotType
) -> None:
if not x or not y:
return
Chart._check_canvas_size(canvas)
Chart._plot_values(canvas, x, y, plot_type)
Chart._stroke_plot_border(canvas)
Chart._draw_min_and_max_values(canvas, x, y)
@staticmethod
def _check_canvas_size(canvas: TextCanvas) -> None:
width = canvas.output.width
height = canvas.output.height
min_width = Chart.HORIZONTAL_MARGIN + 1
min_height = Chart.VERTICAL_MARGIN + 1
if width < min_width or height < min_height:
raise ValueError(
f"Canvas size is {width}×{height}, but must be at least {min_width}×{min_height} to accommodate for plot."
)
@staticmethod
def _plot_values(
canvas: TextCanvas, x: list[float], y: list[float], plot_type: PlotType
) -> None:
width = canvas.output.width - Chart.HORIZONTAL_MARGIN
height = canvas.output.height - Chart.VERTICAL_MARGIN
plot = TextCanvas(width, height)
match plot_type:
case PlotType.LINE:
Plot.line(plot, x, y)
case PlotType.SCATTER:
Plot.scatter(plot, x, y)
canvas.draw_canvas(plot, Chart.MARGIN_LEFT * 2, Chart.MARGIN_TOP * 4)
@staticmethod
def _stroke_plot_border(canvas: TextCanvas) -> None:
top: int = (Chart.MARGIN_TOP - 1) * 4 + 2
right: int = canvas.w - (Chart.MARGIN_RIGHT - 1) * 2
bottom: int = canvas.h - ((Chart.MARGIN_BOTTOM - 1) * 4 + 2)
left: int = (Chart.MARGIN_LEFT - 1) * 2
canvas.stroke_line(left, top, right, top)
canvas.stroke_line(right, top, right, bottom)
canvas.stroke_line(right, bottom, left, bottom)
canvas.stroke_line(left, bottom, left, top)
@staticmethod
def _draw_min_and_max_values(
canvas: TextCanvas, x: list[float], y: list[float]
) -> None:
min_x: str = Chart._format_number(min(x))
max_x: str = Chart._format_number(max(x))
min_y: str = Chart._format_number(min(y))
max_y: str = Chart._format_number(max(y))
canvas.draw_text(
min_x,
Chart.MARGIN_LEFT - len(min_x),
canvas.output.height - Chart.MARGIN_TOP,
)
canvas.draw_text(
max_x,
canvas.output.width - Chart.MARGIN_RIGHT + 2 - len(max_x),
canvas.output.height - Chart.MARGIN_TOP,
)
canvas.draw_text(
min_y,
Chart.MARGIN_LEFT - 2 - len(min_y),
canvas.output.height - Chart.MARGIN_TOP - 1,
)
canvas.draw_text(
max_y,
Chart.MARGIN_LEFT - 2 - len(max_y),
Chart.MARGIN_TOP - 1,
)
@staticmethod
def _format_number(number: float) -> str:
precision = 1
suffix = ""
if abs(number) >= 1_000_000_000_000.0:
number /= 1_000_000_000_000.0
suffix = "T"
elif abs(number) >= 1_000_000_000.0:
number /= 1_000_000_000.0
suffix = "B"
elif abs(number) >= 1_000_000.0:
number /= 1_000_000.0
suffix = "M"
elif abs(number) >= 10_000.0:
number /= 1000.0
suffix = "K"
elif abs(number - round(number)) < 0.001:
precision = 0 # Close enough to being round for display.
if abs(number) < 0.000_1:
number = 0.0 # Prevent "-0".
elif abs(number) < 1.0:
precision = 4 # Sub-1 decimals matter a lot.
return f"{number:.{precision}f}{suffix}"
@staticmethod
def function(
canvas: TextCanvas, from_x: float, to_x: float, f: Callable[[float], float]
) -> None:
"""Render chart with a function.
Examples:
>>> import math
>>> canvas = TextCanvas(35, 10)
>>> f = lambda x: math.cos(x)
>>> Chart.function(canvas, 0.0, 5.0, f)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀1⠀⡤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠉⠉⠢⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠱⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠈⢆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠖⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠣⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠑⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠢⡀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠢⠤⡠⠤⠒⠁⠀⠀⠀⠀⠀⢸⠀
⠀⠀⠀⠀⠀⠀-1⠀⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠚⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀0⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀5
Raises:
ValueError: If chart is < 13×4, because it would make plot
size < 1×1.
"""
nb_values = (canvas.output.width - Chart.HORIZONTAL_MARGIN) * 2
(x, y) = Plot.compute_function(from_x, to_x, nb_values, f)
Chart.line(canvas, x, y)