# SPDX-FileCopyrightText: 2021 Jose David M. # # SPDX-License-Identifier: MIT """ `displayio_cartesian` ================================================================================ A cartesian plane widget for displaying graphical information. * Author(s): Jose David M. Implementation Notes -------------------- **Hardware:** **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: https://github.com/adafruit/circuitpython/releases """ import displayio import terminalio import vectorio from adafruit_display_text import bitmap_label from adafruit_displayio_layout.widgets.widget import Widget try: import bitmaptools except ImportError: pass try: from typing import Any, List, Optional, Tuple except ImportError: pass class Cartesian(Widget): """A cartesian widget. The origin is set using ``x`` and ``y``. :param int x: x position of the plane origin :param int y: y position of the plane origin :param int background_color: background color to use defaults to black (0x000000) :param int width: requested width, in pixels. :param int height: requested height, in pixels. :param (int, int) xrange: X axes range. Defaults to (0, 100) :param (int, int) yrange: Y axes range. Defaults to (0, 100) :param int axes_color: axes lines color defaults to white (0xFFFFFF) :param int axes_stroke: axes lines thickness in pixels defaults to 2 :param int major_tick_stroke: tick lines thickness in pixels defaults to 1 :param int major_tick_length: tick lines length in pixels defaults to 5 :param terminalio.FONT tick_label_font: tick label text font :param int font_color: font color. Defaults to white (0xFFFFFF) :param int pointer_radius: pointer radius in pixels defaults to 1 :param int pointer_color: pointer color. Defaults to white (0xFFFFFF) :param bool subticks: inclusion of subticks in the plot area. Default to False :param int nudge_x: movement in pixels in the x direction to move the origin. Defaults to 0 :param int nudge_y: movement in pixels in the y direction to move the origin. Defaults to 0 :param bool verbose: print debugging information in some internal functions. Default to False **Quickstart: Importing and using Cartesian** Here is one way of importing the `Cartesian` class so you can use it as the name ``Plane``: .. code-block:: python from displayio_cartesian import Cartesian as Plane Now you can create a plane at pixel position x=20, y=30 using: .. code-block:: python my_plane=Plane(x=20, y=30) # instance the plane at x=20, y=30 Once you setup your display, you can now add ``my_plane`` to your display using: .. code-block:: python display.root_group = my_plane # add the group to the display If you want to have multiple display elements, you can create a group and then append the plane and the other elements to the group. Then, you can add the full group to the display as in this example: .. code-block:: python my_plane= Plane(20, 30) # instance the plane at x=20, y=30 my_group = displayio.Group() # make a group my_group.append(my_plane) # Add my_plane to the group # # Append other display elements to the group # display.root_group = my_group # add the group to the display **Summary: Cartesian Features and input variables** The `Cartesian` widget has some options for controlling its position, visible appearance, and scale through a collection of input variables: - **position**: ``x``, ``y``, ``anchor_point``, ``anchored_position`` and ``nudge_x``, ``nudge_y``. Nudge parameters are used to account for the float and int conversions required to display different ranges and values. Conversion are required as displays work in integers and not floats - **size**: ``width`` and ``height`` - **color**: ``axes_color``, ``font_color``, ``tick_color``, ``pointer_color`` - **background color**: ``background_color`` - **linewidths**: ``axes_stroke`` and ``major_tick_stroke`` - **range**: ``xrange`` and ``yrange`` This is the range in absolute units. For example, when using (20-90), the X axis will start at 20 finishing at 90. However, the height of the graph is given by the height parameter. The scale is handled internal to provide a 1:1 experience when you update the graph. .. figure:: cartesian.gif :scale: 100 % :figwidth: 50% :align: center :alt: Diagram of the cartesian widget with the pointer in motion. This is a diagram of a cartesian widget with the pointer moving in the plot area. .. figure:: cartesian_zones.png :scale: 100 % :figwidth: 50% :align: center :alt: Diagram of the cartesian widget zones. This is a diagram of a cartesian widget showing the different zones. .. figure:: cartesian_explanation.png :scale: 100 % :figwidth: 50% :align: center :alt: Diagram of the cartesian widget localisation. This is a diagram of a cartesian widget showing localisation scheme. """ def __init__( self, background_color: int = 0x000000, xrange: Tuple[int, int] = (0, 100), yrange: Tuple[int, int] = (0, 100), axes_color: int = 0xFFFFFF, axes_stroke: int = 1, tick_color: int = 0xFFFFFF, major_tick_stroke: int = 1, major_tick_length: int = 5, tick_label_font: terminalio.FONT = terminalio.FONT, font_color: int = 0xFFFFFF, pointer_radius: int = 1, pointer_color: int = 0xFFFFFF, subticks: bool = False, nudge_x: int = 0, nudge_y: int = 0, verbose: bool = False, **kwargs: Any, ) -> None: super().__init__(**kwargs) self._verbose = verbose self._background_color = background_color self._axes_line_color = axes_color self._axes_line_thickness = axes_stroke self._tick_color = tick_color if major_tick_stroke not in range(1, 5): print("tick thickness must be 1-4 pixels. Defaulting to 1") self._tick_line_thickness = 1 else: self._tick_line_thickness = major_tick_stroke if major_tick_length not in range(1, 9): print("tick length must be 1-10 pixels. Defaulting to 5") self._tick_line_height = 5 else: self._tick_line_height = major_tick_length self._pointer_radius = pointer_radius self._pointer_color = pointer_color self._font = tick_label_font self._font_color = font_color self._font_width = self._get_font_height(self._font, 1)[0] self._font_height = self._get_font_height(self._font, 1)[1] self._xrange = xrange self._normx = (self._xrange[1] - self._xrange[0]) / 100 self._valuex = self.width / 100 self._factorx = 100 / (self._xrange[1] - self._xrange[0]) self._yrange = yrange self._normy = (self._yrange[1] - self._yrange[0]) / 100 self._valuey = self.height / 100 self._factory = 100 / (self._yrange[1] - self._yrange[0]) self._tick_bitmap = displayio.Bitmap(self._tick_line_thickness, self._tick_line_height, 3) self._tick_bitmap.fill(1) self._subticks = subticks axesx_height = ( 2 + self._axes_line_thickness + self._font_height + self._tick_line_height // 2 ) self._axesx_bitmap = displayio.Bitmap(self.width, axesx_height, 4) self._axesx_bitmap.fill(0) self._axesy_width = ( 2 + self._axes_line_thickness + self._font_width + self._tick_line_height // 2 ) self._axesy_bitmap = displayio.Bitmap(self._axesy_width, self.height, 4) self._axesy_bitmap.fill(0) self._plot_bitmap = displayio.Bitmap(self.width, self.height, 5) self.clear_plot_lines() self._screen_palette = displayio.Palette(6) self._screen_palette.make_transparent(0) self._screen_palette[1] = self._tick_color self._screen_palette[2] = self._axes_line_color self._screen_palette[3] = 0x990099 self._screen_palette[4] = 0xFFFFFF self._screen_palette[5] = self._background_color self._corner_bitmap = displayio.Bitmap(10, 10, 5) bitmaptools.fill_region( self._corner_bitmap, 0, 0, self._axes_line_thickness, self._axes_line_thickness, 2, ) self._corner_tilegrid = displayio.TileGrid( self._corner_bitmap, pixel_shader=self._screen_palette, x=-self._axes_line_thickness, y=self.height, ) self._axesx_tilegrid = displayio.TileGrid( self._axesx_bitmap, pixel_shader=self._screen_palette, x=0, y=self.height, ) self._axesy_tilegrid = displayio.TileGrid( self._axesy_bitmap, pixel_shader=self._screen_palette, x=-self._axesy_width, y=0, ) self._screen_tilegrid = displayio.TileGrid( self._plot_bitmap, pixel_shader=self._screen_palette, x=0, y=0, ) self._nudge_x = nudge_x self._nudge_y = nudge_y self._draw_axes() self._draw_ticks() self.append(self._axesx_tilegrid) self.append(self._axesy_tilegrid) self.append(self._screen_tilegrid) self.append(self._corner_tilegrid) self._pointer: Optional[vectorio.Circle] = None self._circle_palette: Optional[displayio.Palette] = None self.plot_line_point: List[Tuple[int, int]] = [] @staticmethod def _get_font_height(font: terminalio.FONT, scale: int) -> Tuple[int, int]: if hasattr(font, "get_bounding_box"): font_height = int(scale * font.get_bounding_box()[1]) font_width = int(scale * font.get_bounding_box()[0]) elif hasattr(font, "ascent"): font_height = int(scale * font.ascent + font.ascent) font_width = 12 else: font_height = 12 font_width = 12 return font_width, font_height def _draw_axes(self) -> None: bitmaptools.fill_region( self._axesx_bitmap, 0, 0, self.width, self._axes_line_thickness, 2, ) bitmaptools.fill_region( self._axesy_bitmap, self._axesy_width - self._axes_line_thickness, 0, self._axesy_width, self.height, 2, ) def _draw_ticks(self) -> None: # ticks definition ticks = [10, 30, 50, 70, 90] subticks = [20, 40, 60, 80, 100] # X axes ticks for i in range(10, 100, 10): text_tick = str(round(self._xrange[0]) + round(i * self._normx)) text_dist = int(self._valuex * i) if i in ticks: shift_label_x = len(text_tick) * self._font_width tick_text = bitmap_label.Label( self._font, color=self._font_color, text=text_tick, x=text_dist - (shift_label_x // 2), y=self.height + self._axes_line_thickness + self._tick_line_height + self._font_height // 2 + 1, ) self.append(tick_text) bitmaptools.fill_region( self._axesx_bitmap, text_dist, self._axes_line_thickness, text_dist + self._tick_line_thickness, self._axes_line_thickness + self._tick_line_height, 1, ) if self._subticks: if i in subticks: # calc subtick_line_height; force min lineheigt to 1. subtick_line_height = max(1, self._tick_line_height // 2) bitmaptools.fill_region( self._axesx_bitmap, text_dist, self._axes_line_thickness, text_dist + 1, self._axes_line_thickness + subtick_line_height, 1, ) # Y axes ticks for i in range(10, 100, 10): text_tick = str(round(self._yrange[0]) + round(i * self._normy)) text_dist = int(self._valuey * i) if i in ticks: shift_label_x = len(text_tick) * self._font_width tick_text = bitmap_label.Label( self._font, color=self._font_color, text=text_tick, x=-shift_label_x - self._axes_line_thickness - self._tick_line_height - 2, y=0 + self.height - text_dist, ) self.append(tick_text) bitmaptools.fill_region( self._axesy_bitmap, self._axesy_width - self._axes_line_thickness - self._tick_line_height - 1, text_dist, self._axesy_width - self._axes_line_thickness - 1, text_dist + self._tick_line_thickness, 1, ) if self._subticks: if i in subticks: bitmaptools.fill_region( self._axesy_bitmap, self._axesy_width - self._axes_line_thickness - self._tick_line_height // 2 - 1, text_dist, self._axesy_width - self._axes_line_thickness - 1, text_dist + 1, 1, ) def _draw_pointers(self, x: int, y: int) -> None: self._circle_palette = displayio.Palette(1) self._circle_palette[0] = self._pointer_color self._pointer = vectorio.Circle( radius=self._pointer_radius, x=x, y=y, pixel_shader=self._circle_palette ) self.append(self._pointer) def _calc_local_xy(self, x: int, y: int) -> Tuple[int, int]: local_x = int((x - self._xrange[0]) * self._factorx * self._valuex) + self._nudge_x # details on `+ (self.height - 1)` : # the bitmap is set to self.width & self.height # but we are only allowed to draw to pixels 0..height-1 and 0..width-1 local_y = ( int((self._yrange[0] - y) * self._factory * self._valuey) + (self.height - 1) + self._nudge_y ) return (local_x, local_y) def _check_local_x_in_range(self, local_x: int) -> bool: return 0 <= local_x < self.width def _check_local_y_in_range(self, local_y: int) -> bool: return 0 <= local_y < self.height def _check_local_xy_in_range(self, local_x: int, local_y: int) -> bool: return self._check_local_x_in_range(local_x) and self._check_local_y_in_range(local_y) def _check_x_in_range(self, x: int) -> bool: return self._xrange[0] <= x <= self._xrange[1] def _check_y_in_range(self, y: int) -> bool: return self._yrange[0] <= y <= self._yrange[1] def _check_xy_in_range(self, x: int, y: int) -> bool: return self._check_x_in_range(x) and self._check_y_in_range(y) def _add_point(self, x: int, y: int) -> None: """_add_point function helper function to add a point to the graph in the plane :param int x: ``x`` coordinate in the local plane :param int y: ``y`` coordinate in the local plane :return: None rtype: None """ local_x, local_y = self._calc_local_xy(x, y) if self._verbose: print("") print( f"xy: ({x: >4}, {y: >4}) " + f"_xrange: ({self._xrange[0]: >4}, {self._xrange[1]: >4}) " + f"_yrange: ({self._yrange[0]: >4}, {self._yrange[1]: >4}) " "" ) print( f"local_*: ({local_x: >4}, {local_y: >4}) " + f" width: ({0: >4}, {self.width: >4}) " + f" height: ({0: >4}, {self.height: >4}) " ) if self._check_xy_in_range(x, y): if self._check_local_xy_in_range(local_x, local_y): if self.plot_line_point is None: self.plot_line_point = [] self.plot_line_point.append((local_x, local_y)) else: # for better error messages we check in detail what failed... # this should never happen: # we already checked the range of the input values. # but in case our calculation is wrong we handle this case to.. if not self._check_local_x_in_range(local_x): raise ValueError( "local_x out of range: " f"local_x:{local_x: >4}; _xrange({0: >4}, {self.width: >4})" "" ) if not self._check_local_y_in_range(local_y): raise ValueError( "local_y out of range: " f"local_y:{local_y: >4}; _yrange({0: >4}, {self.height: >4})" "" ) else: # for better error messages we check in detail what failed... if not self._check_x_in_range(x): raise ValueError( "x out of range: " f"x:{x: >4}; xrange({self._xrange[0]: >4}, {self._xrange[1]: >4})" "" ) if not self._check_y_in_range(y): raise ValueError( "y out of range: " f"y:{y: >4}; yrange({self._yrange[0]: >4}, {self._yrange[1]: >4})" "" ) def update_pointer(self, x: int, y: int) -> None: """updater_pointer function helper function to update pointer in the plane :param int x: ``x`` coordinate in the local plane :param int y: ``y`` coordinate in the local plane :return: None rtype: None """ self._add_point(x, y) if not self._pointer: self._draw_pointers( self.plot_line_point[-1][0], self.plot_line_point[-1][1], ) else: self._pointer.x = self.plot_line_point[-1][0] self._pointer.y = self.plot_line_point[-1][1] def add_plot_line(self, x: int, y: int) -> None: """add_plot_line function. add line to the plane. multiple calls create a line-plot graph. :param int x: ``x`` coordinate in the local plane :param int y: ``y`` coordinate in the local plane :return: None rtype: None """ self._add_point(x, y) if len(self.plot_line_point) > 1: bitmaptools.draw_line( self._plot_bitmap, self.plot_line_point[-2][0], self.plot_line_point[-2][1], self.plot_line_point[-1][0], self.plot_line_point[-1][1], 1, ) def clear_plot_lines(self, palette_index: int = 5) -> None: """clear_plot_lines function. clear all added lines (clear line-plot graph) :param int palette_index: color palett index. Defaults to 5 :return: None """ self.plot_line_point = [] self._plot_bitmap.fill(palette_index)