diff --git a/curvipy/_plotter.py b/curvipy/_plotter.py index decc227..a49eac1 100644 --- a/curvipy/_plotter.py +++ b/curvipy/_plotter.py @@ -21,35 +21,23 @@ class ScreenConfiguration: Background color. Can either be a name or a hex color code. Defaults to "#FFFFFF". window_width : int or None Width of the screen window (in pixels). If None, `window_width` equals to 50% of the \ - display width. Defaults to None. + display width. window_height : int or None Height of the screen window (in pixels). If None, `window_height` equals to 75% of \ - the display height. Defaults to None. - logical_width : int - Logical width of the screen. This is the width that `Plotter` will operate with. - While `window_width` is the real width of the screen, `logical_width` is a virtual \ - representation of it. - logical_height : int - Logical height of the screen. This is the height that `Plotter` will operate with. - While `window_height` is the real height of the screen, `logical_height` is a virtual \ - representation of it. + the display height. """ def __init__( self, window_title: str = "Curvipy", background_color: str = "#FFFFFF", - window_width: _Union[int, None] = None, + window_width: int = None, window_height: int = None, - logical_width: int = 20, - logical_height: int = 20, ): self.window_title = window_title self.background_color = background_color self.window_width = window_width self.window_height = window_height - self.logical_width = logical_width - self.logical_height = logical_height class PlottingConfiguration: @@ -113,64 +101,76 @@ class AxesConfiguration: Parameters ---------- show_axes : bool - If true, x-axis and y-axis are shown. Defaults to True. + If True, x-axis and y-axis are shown. Defaults to True. + show_axes_direction : bool + If True, `Plotter` replaces the last tick of both axes with an arrow that \ + indicates the axis direction. Defaults to False. axes_color : str Axis color. Can either be a name or a hex color code. Defaults to "#70CBCE". axes_width : int Axis width. Defaults to 2. - x_axis_scale : int or float - Real value to define x-axis scale. Defaults to 1. - y_axis_scale : int or float - Real value to define y-axis scale. Defaults to 1. - x_axis_ticks : int - Positive integer. Defaults to 10. - y_axis_ticks : int - Positive integer. Defaults to 10. - x_axis_tick_decimals : int - Positive integer. Defaults to 2. - y_axis_tick_decimals : int - Positive integer. Defaults to 2. - tick_number_font : tuple[str, str, str] + ticks_font : tuple[str, str, str] A triple (fontname, fontsize, fonttype). Defaults to ("Verdana", 8, "normal"). - tick_number_color : str + ticks_color : str Ticks text color. Can either be a name or a hex color code. Defaults to "#000000". - x_axis_tick_number_align : str + x_ticks : int + Number of ticks of half the x-axis, i.e. total x-axis ticks equals `2 * x_ticks`. \ + Defaults to 10. + x_ticks_distance : int + Distance between each x-axis tick. E.g. if `x_ticks_distance` equals 1, x-axis \ + ticks will be `{..., -2, -1, 0, 1, 2, ...}`. Defaults to 1. + x_ticks_decimals : int + Number of decimals of the x-axis ticks number. Defaults to 2. + x_ticks_align : str Can either be "top" or "down". Defines if the x-axis ticks number will be placed \ - upside or downside the y-axis. Defaults to "down". - y_axis_tick_number_align : str + upside or downside the x-axis. Defaults to "down". + x_ticks : int + Number of ticks of half the y-axis, i.e. total y-axis ticks equals `2 * y_ticks`. \ + Defaults to 10. + y_ticks_distance : int + Distance between each y-axis tick. E.g. if `y_ticks_distance` equals 0.5, y-axis \ + ticks will be `{..., -1, -0.5, 0, 0.5, 1, ...}`. Defaults to 1. + y_tick_decimals : int + Number of decimals of the y-axis ticks number. Defaults to 2. + y_ticks_align : str Can either be "left" or "right". Defines if the y-axis ticks number will be placed \ - to the left or to the right of the x-axis. Defaults to "left". + to the left or to the right of the y-axis. Defaults to "left". """ def __init__( self, show_axes: bool = True, + show_axes_direction: bool = False, axes_color: str = "#70CBCE", axes_width: int = 2, - x_axis_scale: _TNumber = 1, - y_axis_scale: _TNumber = 1, - x_axis_ticks: int = 10, - y_axis_ticks: int = 10, - x_axis_tick_decimals: int = 2, - y_axis_tick_decimals: int = 2, - tick_number_font: tuple[str, str, str] = ("Verdana", 8, "normal"), - tick_number_color: str = "#000000", - x_axis_tick_number_align: str = "down", - y_axis_tick_number_align: str = "left", + ticks_font: tuple[str, str, str] = ("Verdana", 8, "normal"), + ticks_color: str = "#000000", + x_ticks: int = 10, + x_ticks_distance: int = 1, + x_ticks_decimals: int = 2, + x_ticks_align: str = "down", + y_ticks: int = 10, + y_ticks_distance: int = 1, + y_ticks_decimals: int = 2, + y_ticks_align: str = "left", ): + # General attributes self.show_axes = show_axes + self.show_axes_direction = show_axes_direction self.axes_color = axes_color self.axes_width = axes_width - self.x_axis_scale = x_axis_scale - self.y_axis_scale = y_axis_scale - self.x_axis_ticks = x_axis_ticks - self.y_axis_ticks = y_axis_ticks - self.x_axis_tick_decimals = x_axis_tick_decimals - self.y_axis_tick_decimals = y_axis_tick_decimals - self.tick_number_font = tick_number_font - self.tick_number_color = tick_number_color - self.x_axis_tick_number_align = x_axis_tick_number_align - self.y_axis_tick_number_align = y_axis_tick_number_align + self.ticks_font = ticks_font + self.ticks_color = ticks_color + # X-axis ticks attributes + self.x_ticks = x_ticks + self.x_ticks_distance = x_ticks_distance + self.x_ticks_decimals = x_ticks_decimals + self.x_ticks_align = x_ticks_align + # Y-axis ticks attributes + self.y_ticks = y_ticks + self.y_ticks_distance = y_ticks_distance + self.y_ticks_decimals = y_ticks_decimals + self.y_ticks_align = y_ticks_align class Plotter: @@ -195,6 +195,35 @@ def __init__( plotting_config: PlottingConfiguration = None, axes_config: AxesConfiguration = None, ) -> None: + # Plotter coordinates system: + # + # `curvipy.Plotter` works with a virtual set of coordinates defined by the a logical width + # and height. The logical screen size is independent of the real window size, and virtual + # coordinates are translated to real window coordinates. + # Despite setting a logical width and height do not affect the window size, it defines the + # xy-plane coordinate system. This is useful for precisely calibrating the distance between + # the axes ticks. + # + # Note that the x-axis length equals the logical width and the y-axis length equals the + # logical height. + # + # If we want the x-axis to have `n` total ticks with a distance `Dx` between their self, that is + # + # axis_ticks = {Dx. i | i in N ^ -n/2 <= i <= n/2} + # = {..., -2.Dx, -Dx, 0, Dx, 2.Dx, ....} + # + # and the y-axis to have `m` total ticks with a distance `Dy` between their self, that is + # + # axis_ticks = {Dy. i | i in N ^ -m/2 <= i <= m/2} + # = {..., -2.Dy, -Dy, 0, Dy, 2.Dy, ....} + # + # then + # + # logical_width = n . Dx + # logical_height = m . Dy + # + + # Public attributes if screen_config is not None: self.screen_config = screen_config else: @@ -210,23 +239,29 @@ def __init__( else: self.axes_config = AxesConfiguration() + # Private attributes + total_x_ticks = 2 * self.axes_config.x_ticks + total_y_ticks = 2 * self.axes_config.y_ticks + + self.__logical_width = total_x_ticks * self.axes_config.x_ticks_distance + self.__logical_height = total_y_ticks * self.axes_config.y_ticks_distance + self.__screen = _ScreenFacade( self.screen_config.window_title, self.screen_config.background_color, self.screen_config.window_width, self.screen_config.window_height, - self.screen_config.logical_width, - self.screen_config.logical_height, + self.__logical_width, + self.__logical_height, ) if self.axes_config.show_axes: self._draw_axis() def _draw_axis(self) -> None: - w, h = self.screen_config.logical_width, self.screen_config.logical_height + w, h = self.__logical_width, self.__logical_height - # Y-AXIS - ## Draw y-axis line + # Draw y-axis line self.__screen.draw_line( (0, -h / 2), (0, h / 2), @@ -235,40 +270,31 @@ def _draw_axis(self) -> None: self.__screen.MAX_DRAWING_SPEED, ) - ## Draw y-axis ticks - dy = ( - 0 - if not self.axes_config.y_axis_ticks - else h / (self.axes_config.y_axis_ticks * 2) - ) + # Draw y-axis ticks + dy = 0 if not self.axes_config.y_ticks else h / (self.axes_config.y_ticks * 2) + for i in range(self.axes_config.y_ticks * 2): + y = dy * (i - self.axes_config.y_ticks) + if y != 0: + self.draw_y_tick(y) + + # Draw y-axis last tick or arrow (see `AxesConfiguration.show_axes_direction` attribute description) + if self.axes_config.show_axes_direction: + axis_arrow_pos = (0, h / 2) + axis_arrow_ang = 0.5 * _pi + self.__screen.draw_arrow( + point=axis_arrow_pos, + arrow_angle=axis_arrow_ang, + arrow_size=10, + arrow_width=self.axes_config.axes_width, + arrow_color=self.axes_config.axes_color, + drawing_speed=self.__screen.MAX_DRAWING_SPEED, + ) - if self.axes_config.y_axis_scale > 0: - i_range = range(self.axes_config.y_axis_ticks * 2) else: - i_range = range(self.axes_config.y_axis_ticks * 2, 0, -1) - - for i in i_range: - y = dy * (i - self.axes_config.y_axis_ticks) - if y == 0: - continue - self.draw_y_tick(y / self.axes_config.y_axis_scale) + last_tick = dy * self.axes_config.y_ticks + self.draw_y_tick(last_tick) - ## Draw y-axis line arrow - axis_arrow_pos = ( - (0, h / 2) if self.axes_config.y_axis_scale > 0 else (0, -h / 2) - ) - axis_arrow_ang = 0.5 * _pi if self.axes_config.y_axis_scale > 0 else -0.5 * _pi - self.__screen.draw_arrow( - point=axis_arrow_pos, - arrow_angle=axis_arrow_ang, - arrow_size=10, - arrow_width=self.axes_config.axes_width, - arrow_color=self.axes_config.axes_color, - drawing_speed=self.__screen.MAX_DRAWING_SPEED, - ) - - # X-AXIS - ## Draw x-axis line + # Draw x-axis line self.__screen.draw_line( (-w / 2, 0), (w / 2, 0), @@ -277,35 +303,28 @@ def _draw_axis(self) -> None: self.__screen.MAX_DRAWING_SPEED, ) - ## Draw x-axis ticks - dx = ( - 0 - if not self.axes_config.x_axis_ticks - else w / (self.axes_config.x_axis_ticks * 2) - ) - - if self.axes_config.x_axis_scale > 0: - i_range = range(self.axes_config.x_axis_ticks * 2) + # Draw x-axis ticks + dx = 0 if not self.axes_config.x_ticks else w / (self.axes_config.x_ticks * 2) + for i in range(self.axes_config.x_ticks * 2): + x = dx * (i - self.axes_config.x_ticks) + if x != 0: + self.draw_x_tick(x) + + # Draw x-axis last tick or arrow (see `AxesConfiguration.show_axes_direction` attribute description) + if self.axes_config.show_axes_direction: + axis_arrow_pos = (w / 2, 0) + axis_arrow_ang = 0 + self.__screen.draw_arrow( + point=axis_arrow_pos, + arrow_angle=axis_arrow_ang, + arrow_size=10, + arrow_width=self.axes_config.axes_width, + arrow_color=self.axes_config.axes_color, + drawing_speed=self.__screen.MAX_DRAWING_SPEED, + ) else: - i_range = range(self.axes_config.x_axis_ticks * 2, 0, -1) - - for i in i_range: - x = dx * (i - self.axes_config.x_axis_ticks) - if x == 0: - continue - self.draw_x_tick(x / self.axes_config.x_axis_scale) - - ## Draw x-axis line arrow - point = (w / 2, 0) if self.axes_config.x_axis_scale > 0 else (-w / 2, 0) - axis_arrow_ang = 0 if self.axes_config.x_axis_scale > 0 else _pi - self.__screen.draw_arrow( - point=point, - arrow_angle=axis_arrow_ang, - arrow_size=10, - arrow_width=self.axes_config.axes_width, - arrow_color=self.axes_config.axes_color, - drawing_speed=self.__screen.MAX_DRAWING_SPEED, - ) + last_tick = dx * self.axes_config.x_ticks + self.draw_x_tick(last_tick) def draw_x_tick(self, number: _TNumber, align: str = None) -> None: """Draws a tick on the x-axis. @@ -317,20 +336,20 @@ def draw_x_tick(self, number: _TNumber, align: str = None) -> None: align : str Can either be "top" or "down". Defines if the x-axis tick number will be placed \ upside or downside the y-axis. If `None`, align takes \ - `Plotter.x_axis_tick_number_align` attribute value. Defaults to `None`. + `Plotter.x_ticks_align` attribute value. Defaults to `None`. """ - # Values for `y_offset`, `text_offset` and `tick_length` have been determined + # NOTE: values for `y_offset`, `text_offset` and `tick_length` have been determined # experimentally. This method work properly for small values of text font size # and well spaced ticks. If the given font is too big or ticks are drawn relative # closely, this method might overlap the ticks with their self or with the axes. # Variables setup - w, h = self.screen_config.logical_width, self.screen_config.logical_height - font_size = self.axes_config.tick_number_font[1] - number_str = str(round(number, self.axes_config.x_axis_tick_decimals)) + w, h = self.__logical_width, self.__logical_height + font_size = self.axes_config.ticks_font[1] + number_str = str(round(number, self.axes_config.x_ticks_decimals)) # Offsets - align = self.axes_config.x_axis_tick_number_align if not align else align + align = self.axes_config.x_ticks_align if not align else align if align == "down": y_offset = -0.04 * h # -4% of the y-axis length if align == "up": @@ -340,19 +359,18 @@ def draw_x_tick(self, number: _TNumber, align: str = None) -> None: # Draw x tick tick_length = 0.01 * h # 1% of the y-axis length - x = number * self.axes_config.x_axis_scale self.__screen.draw_line( - (x, -tick_length), - (x, tick_length), + (number, -tick_length), + (number, tick_length), self.axes_config.axes_width, self.axes_config.axes_color, self.__screen.MAX_DRAWING_SPEED, ) self.__screen.draw_text( text=number_str, - point=(x + text_offset, 0 + y_offset), - text_font=self.axes_config.tick_number_font, - text_color=self.axes_config.tick_number_color, + point=(number + text_offset, 0 + y_offset), + text_font=self.axes_config.ticks_font, + text_color=self.axes_config.ticks_color, align="left", ) @@ -366,21 +384,21 @@ def draw_y_tick(self, number: _TNumber, align: str = None) -> None: align : str Can either be "left" or "right". Defines if the y-axis ticks number will be placed \ to the left or to the right of the x-axis. If `None`, align takes \ - `Plotter.y_axis_tick_number_align` attribute value. Defaults to `None`. + `Plotter.y_ticks_align` attribute value. Defaults to `None`. """ - # Values for `y_offset`, `x_offset`, `tick_length` and `tick_length` have been + # NOTE: values for `y_offset`, `x_offset`, `tick_length` and `tick_length` have been # determined experimentally. This method work properly for small values of text # font size and well spaced ticks. If the given font is too big or ticks are drawn # relative closely, this method might overlap the ticks with their self or with the # axes. # Variables setup - w, h = self.screen_config.logical_width, self.screen_config.logical_height - font_size = self.axes_config.tick_number_font[1] - number_str = str(round(number, self.axes_config.y_axis_tick_decimals)) + w, h = self.__logical_width, self.__logical_height + font_size = self.axes_config.ticks_font[1] + number_str = str(round(number, self.axes_config.y_ticks_decimals)) # Offsets - align = self.axes_config.y_axis_tick_number_align if not align else align + align = self.axes_config.y_ticks_align if not align else align if align == "left": text_offset = font_size * len(number_str) * w * 1e-3 x_offset = -0.02 * w - text_offset # 2% of x-axis length plus text offset @@ -391,19 +409,18 @@ def draw_y_tick(self, number: _TNumber, align: str = None) -> None: # Draw y tick tick_length = 0.01 * w # 1% of the x-axis length - y = number * self.axes_config.y_axis_scale self.__screen.draw_line( - (-tick_length, y), - (tick_length, y), + (-tick_length, number), + (tick_length, number), self.axes_config.axes_width, self.axes_config.axes_color, self.__screen.MAX_DRAWING_SPEED, ) self.__screen.draw_text( text=number_str, - point=(0 + x_offset, y + y_offset), - text_font=self.axes_config.tick_number_font, - text_color=self.axes_config.tick_number_color, + point=(0 + x_offset, number + y_offset), + text_font=self.axes_config.ticks_font, + text_color=self.axes_config.ticks_color, align="left", ) @@ -415,33 +432,32 @@ def plot_vector(self, vector: _Vector) -> None: vector : Vector Vector to be plotted. """ + # NOTE: for plotting the vector arrow, we cannot use the vector angle because + # the vector components do not reflect the real coordinates where it will be + # drawn. Because of that, we need the angle of a scaled version of itself, that + # reflect the real coordinates on screen of the vector. + # + # TODO: + # - Display vector components when `PlottingConfiguration.show_vector_values` + # equals True. + # Check if vector is the cero vector (v = [0, 0]) if not vector.norm: return - # Compute scaled vector - scaled_tail = ( - vector.tail[0] * self.axes_config.x_axis_scale, - vector.tail[1] * self.axes_config.y_axis_scale, - ) - scaled_head = ( - vector.head[0] * self.axes_config.x_axis_scale, - vector.head[1] * self.axes_config.y_axis_scale, - ) - scaled_vector = _Vector(scaled_head, scaled_tail) - # Draw vector self.__screen.draw_line( - scaled_tail, - scaled_head, + vector.tail, + vector.head, self.plotting_config.vector_width, self.plotting_config.vector_color, self.plotting_config.plotting_speed, ) # Draw vector head + scaled_vector = _Vector(self.__screen.get_real_point(vector.components)) self.__screen.draw_arrow( - point=scaled_head, + point=vector.head, arrow_angle=scaled_vector.angle, arrow_size=self.plotting_config.vector_head_size, arrow_width=self.plotting_config.vector_width, @@ -457,17 +473,10 @@ def plot_curve(self, curve: _Curve) -> None: curve : Curve Curve to be plotted. """ - # Curve points - scaled_points = [ - ( - self.axes_config.x_axis_scale * point[0], - self.axes_config.y_axis_scale * point[1], - ) - for point in curve.points() - ] # Draw curve + curve_points = curve.points() self.__screen.draw_polyline( - scaled_points, + curve_points, self.plotting_config.curve_width, self.plotting_config.curve_color, self.plotting_config.plotting_speed, @@ -489,7 +498,6 @@ def plot_animated_curve(self, curve: _Curve, samples_per_vector: int) -> None: for i, vector in enumerate(curve.points()): if i % samples_per_vector != 0: continue - self.plot_vector(_Vector(vector)) # Plot curve: diff --git a/docs/source/documentation.md b/docs/source/documentation.md index 3299bc5..6f0c7ef 100644 --- a/docs/source/documentation.md +++ b/docs/source/documentation.md @@ -7,6 +7,13 @@ :members: ``` +## Screen Configuration + +```{eval-rst} +.. autoclass:: curvipy.ScreenConfiguration + :members: +``` + ## Plotting Configuration ```{eval-rst} @@ -25,25 +32,6 @@ With Curvipy you can plot two-dimensional curves. In this section you can find classes provided by Curvipy for defining curves. -## Interval - -```{eval-rst} -.. autoclass:: curvipy.Interval - :members: -``` - -**Example:** -Suppose we want to plot the function √x with 20 samples. - -```python -import curvipy - -valid_interval = curvipy.Interval(start=0, end=10, samples=20) -# All numbers from start to end belong to √x domain. -invalid_interval = curvipy.Interval(start=-10, end=0, samples=20) -# Negative numbers don't belong to √x domain. -``` - ## Curve ```{eval-rst} @@ -66,6 +54,25 @@ class Sin(Curve): You can define your own [Curve](curvipy.Curve) class or use on the classes provided by Curvipy shown below. +## Interval + +```{eval-rst} +.. autoclass:: curvipy.Interval + :members: +``` + +**Example:** +Suppose we want to plot the function √x with 20 samples. + +```python +import curvipy + +valid_interval = curvipy.Interval(start=0, end=10, samples=20) +# All numbers from start to end belong to √x domain. +invalid_interval = curvipy.Interval(start=-10, end=0, samples=20) +# Negative numbers don't belong to √x domain. +``` + ## Function ```{eval-rst}