diff --git a/adafruit_display_shapes/multisparkline.py b/adafruit_display_shapes/multisparkline.py new file mode 100644 index 0000000..ae07965 --- /dev/null +++ b/adafruit_display_shapes/multisparkline.py @@ -0,0 +1,270 @@ +# SPDX-FileCopyrightText: 2020 Kevin Matocha +# +# SPDX-License-Identifier: MIT + +""" +`multisparkline` +================================================================================ + +Various common shapes for use with displayio - Multiple Sparklines on one chart! + + +* Author(s): Kevin Matocha, Maciej Sokolowski + +Implementation Notes +-------------------- + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +""" + +try: + from typing import Optional, List, TypeVar + + T = TypeVar("T") +except ImportError: + pass +import displayio +from adafruit_display_shapes.polygon import Polygon + + +class _CyclicBuffer: + def __init__(self, size: int, init_value: T) -> None: + self._buffer = [init_value] * size + self._start = 0 # between 0 and size-1 + self._end = 0 # between 0 and 2*size-1 + + def push(self, value: T) -> None: + """Pushes value at the end of the buffer. + + :param T value: value to be pushed + + """ + + if self.len() == len(self._buffer): + raise RuntimeError("Trying to push to full buffer") + self._buffer[self._end % len(self._buffer)] = value + self._end += 1 + + def pop(self) -> T: + """Pop value from the start of the buffer and returns it.""" + + if self.len() == 0: + raise RuntimeError("Trying to pop from empty buffer") + result = self._buffer[self._start] + self._start += 1 + if self._start == len(self._buffer): + self._start -= len(self._buffer) + self._end -= len(self._buffer) + return result + + def len(self) -> int: + """Returns count of valid data in the buffer.""" + + return self._end - self._start + + def clear(self) -> None: + """Marks all data as invalid.""" + + self._start = 0 + self._end = 0 + + def values(self) -> List[T]: + """Returns valid data from the buffer.""" + + if self.len() == 0: + return [] + start = self._start + end = self._end % len(self._buffer) + if start < end: + return self._buffer[start:end] + return self._buffer[start:] + self._buffer[:end] + + +class MultiSparkline(displayio.TileGrid): + """A multiple sparkline graph. + + :param int width: Width of the multisparkline graph in pixels + :param int height: Height of the multisparkline graph in pixels + :param int max_items: Maximum number of values housed in each sparkline + :param bool dyn_xpitch: (Optional) Dynamically change xpitch (True) + :param list y_mins: Lower range for the y-axis per line. + Set each to None for autorange of respective line. + Set to None for autorange of all lines. + :param list y_maxs: Upper range for the y-axis per line. + Set each to None for autorange of respective line. + Set to None for autorange of all lines. + :param int x: X-position on the screen, in pixels + :param int y: Y-position on the screen, in pixels + :param list colors: Each line color. Number of items in this list determines maximum + number of sparklines + + Note: If dyn_xpitch is True (default), each sparkline will allways span + the complete width. Otherwise, each sparkline will grow when you + add values. Once the line has reached the full width, each sparkline + will scroll to the left. + """ + + # pylint: disable=too-many-arguments, too-many-instance-attributes + def __init__( + self, + width: int, + height: int, + max_items: int, + colors: List[int], # each line color + dyn_xpitch: Optional[bool] = True, # True = dynamic pitch size + y_mins: Optional[List[Optional[int]]] = None, # None = autoscaling + y_maxs: Optional[List[Optional[int]]] = None, # None = autoscaling + x: int = 0, + y: int = 0, + ) -> None: + # define class instance variables + self._max_items = max_items # maximum number of items in the list + self._lines = len(colors) + self._buffers = [ + _CyclicBuffer(self._max_items, 0.0) for i in range(self._lines) + ] # values per sparkline + self._points = [ + _CyclicBuffer(self._max_items, (0, 0)) for i in range(self._lines) + ] # _points: all points of sparkline + self.dyn_xpitch = dyn_xpitch + if not dyn_xpitch: + self._xpitch = (width - 1) / (self._max_items - 1) + self.y_mins = ( + [None] * self._lines if y_mins is None else y_mins + ) # minimum of each y-axis (None: autoscale) + self.y_maxs = ( + [None] * self._lines if y_maxs is None else y_maxs + ) # maximum of each y-axis (None: autoscale) + self.y_bottoms = self.y_mins.copy() + # y_bottom: The actual minimum value of the vertical scale, will be + # updated if autorange + self.y_tops = self.y_maxs.copy() + # y_top: The actual maximum value of the vertical scale, will be + # updated if autorange + self._palette = displayio.Palette(self._lines + 1) + self._palette.make_transparent(0) + for (i, color) in enumerate(colors): + self._palette[i + 1] = color + self._bitmap = displayio.Bitmap(width, height, self._lines + 1) + + super().__init__(self._bitmap, pixel_shader=self._palette, x=x, y=y) + + # pylint: enable=too-many-arguments + + def clear_values(self) -> None: + """Clears _buffer and removes all lines in the group""" + self._bitmap.fill(0) + for buffer in self._buffers: + buffer.clear() + + def add_values(self, values: List[float], update: bool = True) -> None: + """Add a value to each sparkline. + + :param list values: The values to be added, one per sparkline + :param bool update: trigger recreation of primitives + + Note: when adding multiple values per sparkline it is more efficient to call + this method with parameter 'update=False' and then to manually + call the update()-method + """ + + for (i, value) in enumerate(values): + if value is not None: + top = self.y_tops[i] + bottom = self.y_bottoms[i] + if ( + self._buffers[i].len() >= self._max_items + ): # if list is full, remove the first item + first = self._buffers[i].pop() + # check if boundaries have to be updated + if self.y_mins[i] is None and first == bottom: + bottom = min(self._buffers[i].values()) + if self.y_maxs[i] is None and first == self.y_tops[i]: + top = max(self._buffers[i].values()) + self._buffers[i].push(value) + + if self.y_mins[i] is None: + bottom = value if not bottom else min(value, bottom) + if self.y_maxs[i] is None: + top = value if not top else max(value, top) + + self.y_tops[i] = top + self.y_bottoms[i] = bottom + + if update: + self.update_line(i) + + def _add_point( + self, + line: int, + x: int, + value: float, + ) -> None: + # Guard for y_top and y_bottom being the same + top = self.y_tops[line] + bottom = self.y_bottoms[line] + if top == bottom: + y = int(0.5 * self.height) + else: + y = int((self.height - 1) * (top - value) / (top - bottom)) + self._points[line].push((x, y)) + + def _draw(self) -> None: + self._bitmap.fill(0) + for i in range(self._lines): + Polygon.draw(self._bitmap, self._points[i].values(), i + 1, close=False) + + def update_line(self, line: int = None) -> None: + """Update the drawing of the sparkline. + param int|None line: Line to update. Set to None for updating all (default). + """ + + if line is None: + lines = range(self._lines) + else: + lines = [line] + + redraw = False + for a_line in lines: + # bail out early if we only have a single point + n_points = self._buffers[a_line].len() + if n_points < 2: + continue + + redraw = True + if self.dyn_xpitch: + # this is a float, only make int when plotting the line + xpitch = (self.width - 1) / (n_points - 1) + else: + xpitch = self._xpitch + + self._points[a_line].clear() # remove all points + + for count, value in enumerate(self._buffers[a_line].values()): + self._add_point(a_line, int(xpitch * count), value) + + if redraw: + self._draw() + + def values_of(self, line: int) -> List[float]: + """Returns the values displayed on the sparkline at given index.""" + + return self._buffers[line].values() + + @property + def width(self) -> int: + """ + :return: the width of the graph in pixels + """ + return self._bitmap.width + + @property + def height(self) -> int: + """ + :return: the height of the graph in pixels + """ + return self._bitmap.height diff --git a/adafruit_display_shapes/polygon.py b/adafruit_display_shapes/polygon.py index 757ec6e..f9ece68 100644 --- a/adafruit_display_shapes/polygon.py +++ b/adafruit_display_shapes/polygon.py @@ -33,121 +33,162 @@ class Polygon(displayio.TileGrid): - # pylint: disable=too-many-arguments,invalid-name """A polygon. :param list points: A list of (x, y) tuples of the points :param int|None outline: The outline of the polygon. Can be a hex value for a color or ``None`` for no outline. + :param bool close: (Optional) Wether to connect first and last point. (True) + :param int colors: (Optional) Number of colors to use. Most polygons would use two, one for + outline and one for fill. If you're not filling your polygon, set this to 1 + for smaller memory footprint. (2) """ + _OUTLINE = 1 + _FILL = 2 + def __init__( self, points: List[Tuple[int, int]], *, outline: Optional[int] = None, + close: Optional[bool] = True, + colors: Optional[int] = 2, ) -> None: - xs = [] - ys = [] - - for point in points: - xs.append(point[0]) - ys.append(point[1]) + (x_s, y_s) = zip(*points) - x_offset = min(xs) - y_offset = min(ys) + x_offset = min(x_s) + y_offset = min(y_s) # Find the largest and smallest X values to figure out width for bitmap - width = max(xs) - min(xs) + 1 - height = max(ys) - min(ys) + 1 + width = max(x_s) - min(x_s) + 1 + height = max(y_s) - min(y_s) + 1 - self._palette = displayio.Palette(3) + self._palette = displayio.Palette(colors + 1) self._palette.make_transparent(0) - self._bitmap = displayio.Bitmap(width, height, 3) + self._bitmap = displayio.Bitmap(width, height, colors + 1) + + shifted = [(x - x_offset, y - y_offset) for (x, y) in points] if outline is not None: - # print("outline") self.outline = outline - for index, _ in enumerate(points): - point_a = points[index] - if index == len(points) - 1: - point_b = points[0] - else: - point_b = points[index + 1] - self._line( - point_a[0] - x_offset, - point_a[1] - y_offset, - point_b[0] - x_offset, - point_b[1] - y_offset, - 1, - ) + self.draw(self._bitmap, shifted, self._OUTLINE, close) super().__init__( self._bitmap, pixel_shader=self._palette, x=x_offset, y=y_offset ) - # pylint: disable=invalid-name, too-many-locals, too-many-branches + @staticmethod + def draw( + bitmap: displayio.Bitmap, + points: List[Tuple[int, int]], + color_id: int, + close: Optional[bool] = True, + ) -> None: + """Draw a polygon conecting points on provided bitmap with provided color_id + + :param displayio.Bitmap bitmap: bitmap to draw on + :param list points: A list of (x, y) tuples of the points + :param int color_id: Color to draw with + :param bool close: (Optional) Wether to connect first and last point. (True) + """ + + if close: + points.append(points[0]) + + for index in range(len(points) - 1): + Polygon._line_on(bitmap, points[index], points[index + 1], color_id) + + # pylint: disable=too-many-arguments def _line( self, - x0: int, - y0: int, - x1: int, - y1: int, + x_0: int, + y_0: int, + x_1: int, + y_1: int, + color: int, + ) -> None: + self._line_on(self._bitmap, (x_0, y_0), (x_1, y_1), color) + + # pylint: enable=too-many-arguments + + @staticmethod + def _safe_draw( + bitmap: displayio.Bitmap, + point: Tuple[int, int], + color: int, + ) -> None: + (x, y) = point + if 0 <= x < bitmap.width and 0 <= y < bitmap.height: + bitmap[x, y] = color + + # pylint: disable=too-many-branches, too-many-locals + @staticmethod + def _line_on( + bitmap: displayio.Bitmap, + p_0: Tuple[int, int], + p_1: Tuple[int, int], color: int, ) -> None: - if x0 == x1: - if y0 > y1: - y0, y1 = y1, y0 - for _h in range(y0, y1 + 1): - self._bitmap[x0, _h] = color - elif y0 == y1: - if x0 > x1: - x0, x1 = x1, x0 - for _w in range(x0, x1 + 1): - self._bitmap[_w, y0] = color + (x_0, y_0) = p_0 + (x_1, y_1) = p_1 + + def pt_on(x, y): + Polygon._safe_draw(bitmap, (x, y), color) + + if x_0 == x_1: + if y_0 > y_1: + y_0, y_1 = y_1, y_0 + for _h in range(y_0, y_1 + 1): + pt_on(x_0, _h) + elif y_0 == y_1: + if x_0 > x_1: + x_0, x_1 = x_1, x_0 + for _w in range(x_0, x_1 + 1): + pt_on(_w, y_0) else: - steep = abs(y1 - y0) > abs(x1 - x0) + steep = abs(y_1 - y_0) > abs(x_1 - x_0) if steep: - x0, y0 = y0, x0 - x1, y1 = y1, x1 + x_0, y_0 = y_0, x_0 + x_1, y_1 = y_1, x_1 - if x0 > x1: - x0, x1 = x1, x0 - y0, y1 = y1, y0 + if x_0 > x_1: + x_0, x_1 = x_1, x_0 + y_0, y_1 = y_1, y_0 - dx = x1 - x0 - dy = abs(y1 - y0) + d_x = x_1 - x_0 + d_y = abs(y_1 - y_0) - err = dx / 2 + err = d_x / 2 - if y0 < y1: + if y_0 < y_1: ystep = 1 else: ystep = -1 - for x in range(x0, x1 + 1): + for x in range(x_0, x_1 + 1): if steep: - self._bitmap[y0, x] = color + pt_on(y_0, x) else: - self._bitmap[x, y0] = color - err -= dy + pt_on(x, y_0) + err -= d_y if err < 0: - y0 += ystep - err += dx + y_0 += ystep + err += d_x - # pylint: enable=invalid-name, too-many-locals, too-many-branches + # pylint: enable=too-many-branches, too-many-locals @property def outline(self) -> Optional[int]: """The outline of the polygon. Can be a hex value for a color or ``None`` for no outline.""" - return self._palette[1] + return self._palette[self._OUTLINE] @outline.setter def outline(self, color: Optional[int]) -> None: if color is None: - self._palette[1] = 0 - self._palette.make_transparent(1) + self._palette[self._OUTLINE] = 0 + self._palette.make_transparent(self._OUTLINE) else: - self._palette[1] = color - self._palette.make_opaque(1) + self._palette[self._OUTLINE] = color + self._palette.make_opaque(self._OUTLINE) diff --git a/adafruit_display_shapes/sparkline.py b/adafruit_display_shapes/sparkline.py index 0ca9661..1efbc29 100644 --- a/adafruit_display_shapes/sparkline.py +++ b/adafruit_display_shapes/sparkline.py @@ -37,18 +37,14 @@ """ -# pylint: disable=too-many-instance-attributes - try: from typing import Optional, List except ImportError: pass -import displayio -from adafruit_display_shapes.line import Line +from adafruit_display_shapes.multisparkline import MultiSparkline -class Sparkline(displayio.Group): - # pylint: disable=too-many-arguments +class Sparkline(MultiSparkline): """A sparkline graph. :param int width: Width of the sparkline graph in pixels @@ -67,6 +63,7 @@ class Sparkline(displayio.Group): will scroll to the left. """ + # pylint: disable=too-many-arguments def __init__( self, width: int, @@ -79,36 +76,11 @@ def __init__( y: int = 0, color: int = 0xFFFFFF, # line color, default is WHITE ) -> None: + super().__init__( + width, height, max_items, [color], dyn_xpitch, [y_min], [y_max], x, y + ) - # define class instance variables - self.width = width # in pixels - self.height = height # in pixels - self.color = color # - self._max_items = max_items # maximum number of items in the list - self._spark_list = [] # list containing the values - self.dyn_xpitch = dyn_xpitch - if not dyn_xpitch: - self._xpitch = (width - 1) / (self._max_items - 1) - self.y_min = y_min # minimum of y-axis (None: autoscale) - self.y_max = y_max # maximum of y-axis (None: autoscale) - self.y_bottom = y_min - # y_bottom: The actual minimum value of the vertical scale, will be - # updated if autorange - self.y_top = y_max - # y_top: The actual minimum value of the vertical scale, will be - # updated if autorange - self._redraw = True # _redraw: redraw primitives - self._last = [] # _last: last point of sparkline - - super().__init__(x=x, y=y) # self is a group of lines - - def clear_values(self) -> None: - """Removes all values from the _spark_list list and removes all lines in the group""" - - for _ in range(len(self)): # remove all items from the current group - self.pop() - self._spark_list = [] # empty the list - self._redraw = True + # pylint: enable=too-many-arguments def add_value(self, value: float, update: bool = True) -> None: """Add a value to the sparkline. @@ -121,167 +93,28 @@ def add_value(self, value: float, update: bool = True) -> None: call the update()-method """ - if value is not None: - if ( - len(self._spark_list) >= self._max_items - ): # if list is full, remove the first item - first = self._spark_list.pop(0) - # check if boundaries have to be updated - if self.y_min is None and first == self.y_bottom: - self.y_bottom = min(self._spark_list) - if self.y_max is None and first == self.y_top: - self.y_top = max(self._spark_list) - self._redraw = True - self._spark_list.append(value) - - if self.y_min is None: - self._redraw = self._redraw or value < self.y_bottom - self.y_bottom = ( - value if not self.y_bottom else min(value, self.y_bottom) - ) - if self.y_max is None: - self._redraw = self._redraw or value > self.y_top - self.y_top = value if not self.y_top else max(value, self.y_top) - - if update: - self.update() - - # pylint: disable=no-else-return - @staticmethod - def _xintercept( - x_1: float, - y_1: float, - x_2: float, - y_2: float, - horizontal_y: float, - ) -> Optional[ - int - ]: # finds intercept of the line and a horizontal line at horizontalY - slope = (y_2 - y_1) / (x_2 - x_1) - b = y_1 - slope * x_1 - - if slope == 0 and y_1 != horizontal_y: # does not intercept horizontalY - return None - else: - xint = ( - horizontal_y - b - ) / slope # calculate the x-intercept at position y=horizontalY - return int(xint) - - def _plotline( - self, - x_1: int, - last_value: float, - x_2: int, - value: float, - ) -> None: - - # Guard for y_top and y_bottom being the same - if self.y_top == self.y_bottom: - y_2 = int(0.5 * self.height) - y_1 = int(0.5 * self.height) - else: - y_2 = int(self.height * (self.y_top - value) / (self.y_top - self.y_bottom)) - y_1 = int( - self.height * (self.y_top - last_value) / (self.y_top - self.y_bottom) - ) - self.append(Line(x_1, y_1, x_2, y_2, self.color)) # plot the line - self._last = [x_2, value] - - # pylint: disable= too-many-branches, too-many-nested-blocks, too-many-locals, too-many-statements + self.add_values([value], update) def update(self) -> None: """Update the drawing of the sparkline.""" - # bail out early if we only have a single point - n_points = len(self._spark_list) - if n_points < 2: - self._last = [0, self._spark_list[0]] - return - - if self.dyn_xpitch: - # this is a float, only make int when plotting the line - xpitch = (self.width - 1) / (n_points - 1) - self._redraw = True - else: - xpitch = self._xpitch - - # only add new segment if redrawing is not necessary - if not self._redraw: - # end of last line (last point, read as "x(-1)") - x_m1 = self._last[0] - y_m1 = self._last[1] - # end of new line (new point, read as "x(0)") - x_0 = int(x_m1 + xpitch) - y_0 = self._spark_list[-1] - self._plotline(x_m1, y_m1, x_0, y_0) - return - - self._redraw = False # reset, since we now redraw everything - for _ in range(len(self)): # remove all items from the current group - self.pop() - - for count, value in enumerate(self._spark_list): - if count == 0: - pass # don't draw anything for a first point - else: - x_2 = int(xpitch * count) - x_1 = int(xpitch * (count - 1)) - - if (self.y_bottom <= last_value <= self.y_top) and ( - self.y_bottom <= value <= self.y_top - ): # both points are in range, plot the line - self._plotline(x_1, last_value, x_2, value) - - else: # at least one point is out of range, clip one or both ends the line - if ((last_value > self.y_top) and (value > self.y_top)) or ( - (last_value < self.y_bottom) and (value < self.y_bottom) - ): - # both points are on the same side out of range: don't draw anything - pass - else: - xint_bottom = self._xintercept( - x_1, last_value, x_2, value, self.y_bottom - ) # get possible new x intercept points - xint_top = self._xintercept( - x_1, last_value, x_2, value, self.y_top - ) # on the top and bottom of range - if (xint_bottom is None) or ( - xint_top is None - ): # out of range doublecheck - pass - else: - # Initialize the adjusted values as the baseline - adj_x_1 = x_1 - adj_last_value = last_value - adj_x_2 = x_2 - adj_value = value - - if value > last_value: # slope is positive - if xint_bottom >= x_1: # bottom is clipped - adj_x_1 = xint_bottom - adj_last_value = self.y_bottom # y_1 - if xint_top <= x_2: # top is clipped - adj_x_2 = xint_top - adj_value = self.y_top # y_2 - else: # slope is negative - if xint_top >= x_1: # top is clipped - adj_x_1 = xint_top - adj_last_value = self.y_top # y_1 - if xint_bottom <= x_2: # bottom is clipped - adj_x_2 = xint_bottom - adj_value = self.y_bottom # y_2 - - self._plotline( - adj_x_1, - adj_last_value, - adj_x_2, - adj_value, - ) - - last_value = value # store value for the next iteration + self.update_line(0) def values(self) -> List[float]: """Returns the values displayed on the sparkline.""" - return self._spark_list + return self.values_of(0) + + @property + def y_top(self) -> float: + """ + :return: The actual maximum value of the vertical scale, will be updated if autorange + """ + return self.y_tops[0] + + @property + def y_bottom(self) -> float: + """ + :return: The actual minimum value of the vertical scale, will be updated if autorange + """ + return self.y_bottoms[0] diff --git a/adafruit_display_shapes/triangle.py b/adafruit_display_shapes/triangle.py index 6d88464..27579a8 100644 --- a/adafruit_display_shapes/triangle.py +++ b/adafruit_display_shapes/triangle.py @@ -101,7 +101,7 @@ def __init__( point_a[1] - y0, point_b[0] - min(xs), point_b[1] - y0, - 1, + self._OUTLINE, ) # pylint: disable=invalid-name, too-many-branches @@ -126,7 +126,7 @@ def _draw_filled( a = x2 elif x2 > b: b = x2 - self._line(a, y0, b, y0, 2) + self._line(a, y0, b, y0, self._FILL) return if y1 == y2: @@ -140,7 +140,7 @@ def _draw_filled( b = round(x0 + (x2 - x0) * (y - y0) / (y2 - y0)) if a > b: a, b = b, a - self._line(a, y, b, y, 2) + self._line(a, y, b, y, self._FILL) # Lower Triangle for y in range(last + 1, y2 + 1): a = round(x1 + (x2 - x1) * (y - y1) / (y2 - y1)) @@ -148,7 +148,7 @@ def _draw_filled( if a > b: a, b = b, a - self._line(a, y, b, y, 2) + self._line(a, y, b, y, self._FILL) # pylint: enable=invalid-name, too-many-locals, too-many-branches @@ -156,13 +156,13 @@ def _draw_filled( def fill(self) -> Optional[int]: """The fill of the triangle. Can be a hex value for a color or ``None`` for transparent.""" - return self._palette[2] + return self._palette[self._FILL] @fill.setter def fill(self, color: Optional[int]) -> None: if color is None: - self._palette[2] = 0 - self._palette.make_transparent(2) + self._palette[self._FILL] = 0 + self._palette.make_transparent(self._FILL) else: - self._palette[2] = color - self._palette.make_opaque(2) + self._palette[self._FILL] = color + self._palette.make_opaque(self._FILL)