From 17a6fdc3d19b728e24405977e0c79e54fc0ea918 Mon Sep 17 00:00:00 2001 From: Kevin McAleer Date: Sat, 22 Feb 2025 10:11:37 +0000 Subject: [PATCH] updated to version 2.0 --- pichart.py | 952 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 566 insertions(+), 386 deletions(-) diff --git a/pichart.py b/pichart.py index 8e9d6a5..18c6c17 100644 --- a/pichart.py +++ b/pichart.py @@ -1,436 +1,616 @@ # PiChart # Tiny dashboard Charts for MicroPython # Kevin McAleer -# June 2022 - -# use jpegdec - part of the Pimoroni Batteries included MicroPython build -import jpegdec +# June 2022, Improved February 2025 + +VERSION = "2.1.0" + +import jpegdec + +# Module-level constants +DEFAULT_COLORS = { + 'BACKGROUND': {'red': 0, 'green': 0, 'blue': 0}, + 'BORDER': {'red': 0, 'green': 0, 'blue': 0}, + 'GRID': {'red': 0, 'green': 0, 'blue': 0}, + 'TITLE': {'red': 0, 'green': 0, 'blue': 0}, + 'DATA': {'red': 0, 'green': 0, 'blue': 0}, +} +DEFAULT_SIZES = { + 'DATA_POINT_RADIUS': 2, + 'DATA_POINT_WIDTH': 10, + 'BORDER_WIDTH': 2, + 'TEXT_HEIGHT': 16, + 'GRID_SPACING': 10, + 'BAR_GAP': 3, +} +DEBUG = False # Toggle for debug output + +def log_debug(message: str) -> None: + """Print debug messages if DEBUG is True. + + Args: + message: The debug message to print. + """ + if DEBUG: + print(f"DEBUG: {message}") class Chart: - """ Models a Chart, for displaying data""" - title = '' # The title of the chart - x_values = [] # Holds the chart data - x_offset = 0 # The x offset of the chart - y_offset = 0 # The y offset of the chart - - # Set the chart colours - background_colour = {'red': 0, 'green': 0, 'blue': 0} - border_colour = {'red': 0, 'green': 0, 'blue': 0} - grid_colour = {'red':border_colour['red']//4, 'green':border_colour['green']//4,'blue':border_colour['blue']//4} - title_colour = {'red': 0, 'green': 0, 'blue': 0} - data_colour = {'red': 0, 'green': 0, 'blue': 0} - - # Data point settings - data_point_radius = 2 - data_point_radius2 = data_point_radius * 4 - data_point_width = 10 - - # chart measurements - x = 0 - y = 0 - width = 100 - height = 100 - border_width = 2 - text_height = 16 - - # Configurable settings - show_datapoints = False - show_lines = False - show_bars = True - __show_labels = False # Show the labels does more than just set one value, so it's a method - grid = True - grid_spacing = 10 - bar_gap = 3 - - def __init__(self, display, title=None, x_label=None, y_label=None, x_values=None, y_values=None): - """ Initialise the chart """ - self.display = display + """A chart for plotting data on a MicroPython display. + + Use this to visualize data as bars, lines, or points. Set position, size, and colors + after creation if needed. + + Attributes: + x, y: Position on the display (default 0, 0). + width, height: Size of the chart (default 100, 100). + show_bars: Show data as bars (default True). + show_lines: Connect data points with lines (default False). + show_datapoints: Show data points as circles (default False). + """ + + def __init__(self, display, title: str = "", x_label: str = None, y_label: str = None, + values: list = None): + """Create a new chart. + + Args: + display: The display object (e.g., PicoGraphics). + title: Chart title (default empty string). + x_label: X-axis label (optional). + y_label: Y-axis label (optional). + values: List of numeric data to plot (default empty list). + + Raises: + ValueError: If display is None or values contain non-numeric data. + """ + if not display: + raise ValueError("Display object is required") - if title: self.title = title - if x_label: self.__x_label = x_label - if y_label: self.__y_label = y_label - if x_values: - # Scale the data - self.x_values = x_values - self.min_val = min(self.x_values) # get the minimum value - self.max_val = max(self.x_values) # get the maximum value - if y_values: self.__y_values = y_values - - @property - def show_labels(self): - """ Get the show_labels value """ - return self.__show_labels - - @show_labels.setter - def show_labels(self, value): - """ Set the show_labels value """ - self.__show_labels = value - self.data_point_radius2 = self.data_point_radius - - def draw_border(self): - """ Draw the border of the chart """ - border_colour = self.display.create_pen(self.border_colour['red'], self.border_colour['green'], self.border_colour['blue']) - background_colour = self.display.create_pen(self.background_colour['red'], self.background_colour['green'], self.background_colour['blue']) - self.display.set_pen(border_colour) - x = self.x - y = self.y - w = self.width - h = self.height - x1 = x+w - y1 = y+h - self.display.set_clip(x,y,x1,y1) - - # Draw the 4 border lines - for i in range(0,self.border_width,1): - self.display.line(x+i, y+i, x+i, y1-i) # left - self.display.line(x+i, y+i, x1-i, y+i) # top - self.display.line(x+i, y1-i-1, x1-i, y1-i-1) # bottom - self.display.line(x1-i-1, y+i, x1-i-1, y1) # right + self._display = display + self._pen_cache = {} # Cache for pens to reduce memory usage + self.title = title + self._x_label = x_label + self._y_label = y_label + self.values = values or [] + self._min_val = None + self._max_val = None + self._y_scale = 1 - self.display.set_pen(background_colour) - self.display.remove_clip() - - def draw_grid(self): - """ Draw the grid behind the chart """ + # Positioning and size + self.x = 0 + self.y = 0 + self.width = 100 + self.height = 100 + self.border_width = DEFAULT_SIZES['BORDER_WIDTH'] + self.text_height = DEFAULT_SIZES['TEXT_HEIGHT'] - # Set the colour of the grid - grid_colour = self.display.create_pen(self.grid_colour['red'], self.grid_colour['green'], self.grid_colour['blue']) - self.display.set_pen(grid_colour) + # Display options + self.show_datapoints = False + self.show_lines = False + self.show_bars = True + self._show_labels = False + self.grid = True + self.grid_spacing = DEFAULT_SIZES['GRID_SPACING'] + self.bar_gap = DEFAULT_SIZES['BAR_GAP'] + self.data_point_radius = DEFAULT_SIZES['DATA_POINT_RADIUS'] + self.data_point_width = DEFAULT_SIZES['DATA_POINT_WIDTH'] - # Create values - x = self.x - y = self.y - w = self.width - h = self.height - - # Calculate columns - cols = w // self.grid_spacing - row = h // self.grid_spacing - - # Draw Grid - for i in range(cols): - self.display.line(x+self.grid_spacing*i, y, x+self.grid_spacing*i, y+h) - - for j in range(row): - self.display.line(x, y+self.grid_spacing*j, x+w, y+self.grid_spacing*j) + # Colors + self.background_colour = DEFAULT_COLORS['BACKGROUND'].copy() + self.border_colour = DEFAULT_COLORS['BORDER'].copy() + self.grid_colour = DEFAULT_COLORS['GRID'].copy() + self.title_colour = DEFAULT_COLORS['TITLE'].copy() + self.data_colour = DEFAULT_COLORS['DATA'].copy() - def map(self, x, in_min, in_max, out_min, out_max): - """ Map a value from one range to another """ - return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min - - def scale_data(self): - """ Scale the data to fit the chart """ + # Validate and scale data if provided + if self.values: + self._validate_data(self.values) + self._scale_data() + + def set_values(self, new_values: list) -> None: + """Update the chart data and recalculate scaling. + + Args: + new_values: New list of numeric data to plot. + """ + self.values = new_values or [] + if self.values: + self._validate_data(self.values) + self._scale_data() - self.min_val = min(self.x_values) # get the minimum value - self.max_val = max(self.x_values) # get the maximum value - - # Calculate the scale - if self.max_val - self.min_val == 0: - self.max_val = 2 - self.min_val = 1 - self.__y_scale = (((self.height - self.text_height) - self.border_width * 2)) // (self.max_val - self.min_val) - - def update(self): - """ Update the chart """ - - self.display.set_clip(self.x, self.y, self.x+self.width, self.y+self.height) - background_colour = self.display.create_pen(self.background_colour['red'], self.background_colour['green'], self.background_colour['blue']) - self.display.set_pen(background_colour) - self.display.rectangle(self.x, self.y, self.width, self.height) - - self.display.remove_clip() - - # Draw the Grid - if self.grid: - self.draw_grid() - - # display the Title - title_colour = self.display.create_pen(self.title_colour['red'], self.title_colour['green'], self.title_colour['blue']) - data_colour = self.display.create_pen(self.data_colour['red'], self.data_colour['green'], self.data_colour['blue']) - data_colour2 = self.display.create_pen(self.data_colour['red']//4, self.data_colour['green']//4, self.data_colour['blue']//4) - - self.display.set_clip(self.x+self.bar_gap, self.y+self.bar_gap, (self.x+self.width)-self.bar_gap, (self.y+self.height)-self.bar_gap) - self.display.set_pen(title_colour) - self.display.text(self.title, self.x+self.border_width+1, self.y + self.border_width+1,self.width) - self.display.set_pen(data_colour) - self.display.remove_clip() - - # Work out the area offset - self.x_offset = self.border_width+2 - self.y_offset = (self.height-self.border_width)-2 - - x_pos = self.x + self.x_offset - y_pos = self.y + self.y_offset - - prev_x = x_pos - prev_y = y_pos - - - # The area within the chart - plot_area = (self.height - self.text_height) - self.border_width*2 - self.display.set_clip(self.x+self.border_width, self.y+self.border_width+self.text_height, self.x+self.width, (self.y+self.height)-self.border_width) - - for item in self.x_values: - val = item - item = int(self.map(item, self.min_val, self.max_val,0,plot_area)) # scale the data - - # calculate data visual height - data_height = int(item) - print(f'data height: {data_height}, title:{self.title}') - - if self.show_bars: - self.display.rectangle(x_pos, y_pos-item, self.data_point_width, data_height) - - if self.show_datapoints: - self.display.set_pen(data_colour2) - self.display.circle(x_pos, y_pos-item, self.data_point_radius2) - self.display.set_pen(data_colour) - self.display.circle(x_pos, y_pos-item, self.data_point_radius) - - if self.show_lines: - self.display.line(x_pos, y_pos-item, prev_x, prev_y) - - if self.show_labels: - self.display.text(str(val), x_pos-4, y_pos-item -10, self.width - y_pos, 1) - - prev_x = x_pos - prev_y = y_pos-item - x_pos += self.data_point_width+1 - - self.display.remove_clip() - - # Draw the border - self.draw_border() - - self.display.update() - - -class Card(Chart): - """ A card class """ - - # Set the default values - text_scale = 20 - margin = 2 - - def __init__(self, display, x=None, y=None, width=None, height=None, title=None): - """ Initialise the card """ - self.display = display - if x: self.x = x - if y: self.y = y - if width: self.width = width - if height: self.height = height - if title: self.title = title - - def scale_text(self): - """ Scale the text """ - self.text_scale = 20 - - # Cycle through a couple of different scales until the text fits - while True: - self.display.set_font("bitmap8") - name_length = self.display.measure_text(self.title, self.text_scale) - if name_length >= self.width - self.margin*2: - self.text_scale -= 1 - else: - break - - if self.text_scale * 8 > self.height: - self.text_scale = self.height // 8 - - def update(self): - """ Update the card """ - - # Set the colours - background_color = self.display.create_pen(self.background_colour['red'], self.background_colour['green'], self.background_colour['blue']) - title_color = self.display.create_pen(self.title_colour['red'], self.title_colour['green'], self.title_colour['blue']) - self.display.set_pen(background_color) - - # Clear the display, without flickering - self.display.rectangle(self.x, self.y, self.width, self.height) - - # Draw the Grid, if its enabled - if self.grid: - self.draw_grid() - - # Draw the border - self.draw_border() - - # Centered title - self.scale_text() - text_length = self.display.measure_text(self.title, self.text_scale) - - - # title_x = ((text_length - self.width) //2) - title_y = (self.y + self.height // 2) - (self.text_scale * 8) // 2 - - # Draw the title - self.display.set_pen(title_color) - self.display.text(self.title, self.x + ((self.width //2) - (text_length //2)), title_y, text_length, self.text_scale) - - # Update the display - self.display.update() - -class Image_tile: - """ A class for displaying an image """ - image_file = None - x = 0 - y = 0 - width = 0 - height = 0 - border_colour = {'red':0, 'green':0, 'blue':0} - border_width = 2 - - def __init__(self, display, filename=None): - """ Initialise the image tile """ - self.display = display - if filename: - self.image_file = filename - - def draw_border(self): - """ Draw the border """ - border_colour = self.display.create_pen(self.border_colour['red'], self.border_colour['green'], self.border_colour['blue']) - # background_colour = self.display.create_pen(self.background_colour['red'], self.background_colour['green'], self.background_colour['blue']) - self.display.set_pen(border_colour) - x = self.x - y = self.y - w = self.width - h = self.height - x1 = x+w - y1 = y+h - self.display.set_clip(x,y,x1,y1) - - # Draw the 4 border lines - for i in range(0,self.border_width,1): - self.display.line(x+i, y+i, x+i, y1-i) # left - self.display.line(x+i, y+i, x1-i, y+i) # top - self.display.line(x+i, y1-i-1, x1-i, y1-i-1) # bottom - self.display.line(x1-i-1, y+i, x1-i-1, y1) # right - - # self.display.set_pen(background_colour) - self.display.remove_clip() + @property + def show_labels(self) -> bool: + """Whether to show data value labels above points or bars.""" + return self._show_labels - def update(self): - """ Display the image tile""" - - j = jpegdec.JPEG(self.display) + @show_labels.setter + def show_labels(self, value: bool) -> None: + """Set whether to show data value labels. + + Args: + value: True to show labels, False to hide them. + """ + self._show_labels = bool(value) + self.data_point_radius = DEFAULT_SIZES['DATA_POINT_RADIUS'] * (4 if value else 1) + + def _get_pen(self, color: dict) -> int: + """Get a pen (color) from the cache or create a new one. + + Args: + color: Dict with 'red', 'green', 'blue' keys (0-255). + + Returns: + Pen ID for the display. + """ + color_key = (color['red'], color['green'], color['blue']) + if color_key not in self._pen_cache: + self._pen_cache[color_key] = self._display.create_pen( + color['red'], color['green'], color['blue'] + ) + return self._pen_cache[color_key] + + def _validate_data(self, values: list) -> None: + """Check that data is valid. + + Args: + values: List of data to validate. + + Raises: + ValueError: If values are empty or contain non-numeric items. + """ + if not values: + raise ValueError("Data values cannot be empty") + if not all(isinstance(v, (int, float)) for v in values): + raise ValueError("All data values must be numeric") + if any(v < -1000 or v > 1000 for v in values): + log_debug("Data values outside typical range (-1000 to 1000)") + + def _scale_data(self) -> None: + """Adjust data scale to fit the chart height.""" + self._min_val = min(self.values) + self._max_val = max(self.values) + if self._max_val == self._min_val: + self._max_val += 1 # Avoid division by zero + self._min_val -= 1 + plot_height = (self.height - self.text_height) - (self.border_width * 2) + self._y_scale = plot_height / (self._max_val - self._min_val) + + @staticmethod + def map_value(x: float, in_min: float, in_max: float, out_min: float, out_max: float) -> float: + """Map a value from one range to another. + + Args: + x: Value to map. + in_min: Minimum of input range. + in_max: Maximum of input range. + out_min: Minimum of output range. + out_max: Maximum of output range. + + Returns: + Mapped value as a float. + """ + if in_max == in_min: + return out_min + return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min - # Open the JPEG file - j.open_file(self.filename) + def draw_border(self) -> None: + """Draw a border around the chart.""" + border_pen = self._get_pen(self.border_colour) + self._display.set_pen(border_pen) + x, y = self.x, self.y + w, h = self.width, self.height + x1, y1 = x + w, y + h + self._display.set_clip(x, y, x1, y1) + + for i in range(self.border_width): + self._display.line(x + i, y + i, x + i, y1 - i) # Left + self._display.line(x + i, y + i, x1 - i, y + i) # Top + self._display.line(x + i, y1 - i - 1, x1 - i, y1 - i - 1) # Bottom + self._display.line(x1 - i - 1, y + i, x1 - i - 1, y1 - i) # Right + + self._display.remove_clip() + + def draw_grid(self) -> None: + """Draw a grid inside the chart.""" + grid_pen = self._get_pen(self.grid_colour) + self._display.set_pen(grid_pen) + x, y = self.x, self.y + w, h = self.height, self.width - self.display.set_clip(self.x, self.y, self.width, self.height) + cols = w // self.grid_spacing + rows = h // self.grid_spacing - # Decode the JPEG - j.decode(self.x, self.y, jpegdec.JPEG_SCALE_HALF) + for i in range(cols): + self._display.line(x + self.grid_spacing * i, y, x + self.grid_spacing * i, y + h) + for j in range(rows): + self._display.line(x, y + self.grid_spacing * j, x + w, y + self.grid_spacing * j) + + def update(self) -> None: + """Draw the chart on the display. + + Call this to refresh the chart after changing data or settings. + """ + try: + if not self.values: + log_debug("No data to display") + return + + # Clear the chart area + background_pen = self._get_pen(self.background_colour) + self._display.set_pen(background_pen) + self._display.set_clip(self.x, self.y, self.x + self.width, self.y + self.height) + self._display.rectangle(self.x, self.y, self.width, self.height) + self._display.remove_clip() + + # Draw grid if enabled + if self.grid: + self.draw_grid() + + # Draw title + title_pen = self._get_pen(self.title_colour) + self._display.set_pen(title_pen) + self._display.text(self.title, self.x + self.border_width + 1, + self.y + self.border_width + 1, self.width) + + # Prepare data drawing + data_pen = self._get_pen(self.data_colour) + data_pen_dim = self._get_pen({ + 'red': self.data_colour['red'] // 4, + 'green': self.data_colour['green'] // 4, + 'blue': self.data_colour['blue'] // 4 + }) + plot_area = (self.height - self.text_height) - (self.border_width * 2) + x_pos = self.x + self.border_width + 2 + y_base = self.y + self.height - self.border_width - 2 + prev_x, prev_y = x_pos, y_base + + self._display.set_clip(self.x + self.border_width, self.y + self.text_height, + self.x + self.width, self.y + self.height - self.border_width) + + for idx, value in enumerate(self.values): + scaled_height = int(self.map_value(value, self._min_val, self._max_val, 0, plot_area)) + y_pos = y_base - scaled_height + log_debug(f"Value: {value}, Scaled height: {scaled_height}, X: {x_pos}, Y: {y_pos}") + + if self.show_bars: + self._display.set_pen(data_pen) + self._display.rectangle(x_pos, y_pos, self.data_point_width, scaled_height) + + if self.show_datapoints: + self._display.set_pen(data_pen_dim) + self._display.circle(x_pos + self.data_point_width // 2, y_pos, self.data_point_radius * 2) + self._display.set_pen(data_pen) + self._display.circle(x_pos + self.data_point_width // 2, y_pos, self.data_point_radius) + + if self.show_lines and idx > 0: + self._display.set_pen(data_pen) + self._display.line(prev_x + self.data_point_width // 2, prev_y, + x_pos + self.data_point_width // 2, y_pos) + + if self._show_labels: + self._display.set_pen(data_pen) + self._display.text(str(value), x_pos, y_pos - 10, self.width - x_pos) + + prev_x, prev_y = x_pos, y_pos + x_pos += self.data_point_width + self.bar_gap + + self._display.remove_clip() + self.draw_border() + self._display.update() + + except Exception as e: + log_debug(f"Chart update error: {e}") - self.display.remove_clip() - # Draw the border - self.draw_border() +class Card(Chart): + """A simple text card for displaying information. + + Inherits from Chart but only shows text, no data plotting. + + Attributes: + x, y: Position on the display. + width, height: Size of the card. + """ + + def __init__(self, display, x: int = 0, y: int = 0, width: int = 100, height: int = 100, + title: str = ""): + """Create a new card. + + Args: + display: The display object (e.g., PicoGraphics). + x: X position (default 0). + y: Y position (default 0). + width: Width in pixels (default 100). + height: Height in pixels (default 100). + title: Text to display (default empty string). + """ + super().__init__(display, title=title) + self.x = x + self.y = y + self.width = width + self.height = height + self._text_scale = 1 + self.grid = False # Cards typically don’t need grids + + def _scale_text(self) -> int: + """Find the best text scale to fit the title. + + Returns: + Scale factor (1 or higher). + """ + self._display.set_font("bitmap8") + max_width = self.width - (self.border_width * 2) + max_height = self.height - (self.border_width * 2) + scale = 2 # Start with a reasonable scale + + while scale > 0: + text_width = self._display.measure_text(self.title, scale) + text_height = 8 * scale + if text_width <= max_width and text_height <= max_height: + return scale + scale -= 1 + return 1 + + def update(self) -> None: + """Draw the card on the display.""" + try: + background_pen = self._get_pen(self.background_colour) + title_pen = self._get_pen(self.title_colour) + self._display.set_pen(background_pen) + self._display.rectangle(self.x, self.y, self.width, self.height) + + if self.grid: + self.draw_grid() + + self.draw_border() + self._text_scale = self._scale_text() + text_length = self._display.measure_text(self.title, self._text_scale) + title_x = self.x + (self.width - text_length) // 2 + title_y = self.y + (self.height - (self._text_scale * 8)) // 2 + + self._display.set_pen(title_pen) + self._display.text(self.title, title_x, title_y, text_length, self._text_scale) + self._display.update() + + except Exception as e: + log_debug(f"Card update error: {e}") + +class ImageTile: + """A tile for showing an image with a border. + + Uses JPEG decoding via the jpegdec library. + + Attributes: + x, y: Position on the display. + width, height: Size of the tile. + """ + + def __init__(self, display, filename: str = None, x: int = 0, y: int = 0, + width: int = 100, height: int = 100): + """Create a new image tile. + + Args: + display: The display object (e.g., PicoGraphics). + filename: Path to the JPEG file (default None). + x: X position (default 0). + y: Y position (default 0). + width: Width in pixels (default 100). + height: Height in pixels (default 100). + + Raises: + ValueError: If display is None. + """ + if not display: + raise ValueError("Display object is required") + self._display = display + self.filename = filename + self.x = x + self.y = y + self.width = width + self.height = height + self.border_colour = DEFAULT_COLORS['BORDER'].copy() + self.border_width = DEFAULT_SIZES['BORDER_WIDTH'] + + def draw_border(self) -> None: + """Draw a border around the image.""" + border_pen = self._display.create_pen( + self.border_colour['red'], self.border_colour['green'], self.border_colour['blue'] + ) + self._display.set_pen(border_pen) + x, y = self.x, self.y + w, h = self.width, self.height + x1, y1 = x + w, y + h + self._display.set_clip(x, y, x1, y1) + + for i in range(self.border_width): + self._display.line(x + i, y + i, x + i, y1 - i) # Left + self._display.line(x + i, y + i, x1 - i, y + i) # Top + self._display.line(x + i, y1 - i - 1, x1 - i, y1 - i - 1) # Bottom + self._display.line(x1 - i - 1, y + i, x1 - i - 1, y1 - i) # Right + + self._display.remove_clip() + + def update(self) -> None: + """Draw the image tile on the display.""" + try: + if not self.filename: + log_debug("No image file specified") + return + j = jpegdec.JPEG(self._display) + j.open_file(self.filename) + self._display.set_clip(self.x, self.y, self.x + self.width, self.y + self.height) + j.decode(self.x, self.y, jpegdec.JPEG_SCALE_HALF) + self._display.remove_clip() + self.draw_border() + self._display.update() + except Exception as e: + log_debug(f"ImageTile update error: {e}") class Container: - """ A container class """ - - charts = [] - cols = 1 - __background_colour = {'red':0, 'green':0, 'blue':0} - __title_colour = {'red':0, 'green':0, 'blue':0} - __data_colour = {'red':0, 'green':0, 'blue':0} - __grid_colour = {'red':0, 'green':0, 'blue':0} - - def __init__(self, display, width=None, height=None): - self.display = display - if width: - self.width = width - if height: - self.height = height - else: - self.width, self.height = display.get_bounds() - - def add_chart(self, item): - """ Add an item to the container """ - self.charts.append(item) - - def update(self): - """ Update the container """ - - rows = len(self.charts) // self.cols - item_count = rows // self.cols - - count = 1 - for col in range(1,self.cols+1,1): - for row in range(1, rows+1, 1): - item_index = count - item = self.charts[item_index-1] - item.height = self.height // rows - item.y = self.height - (row * item.height) - item.width = self.width // self.cols - item.x = self.width - (self.width // col) - count +=1 - if count > 4: - count = 1 - for item in self.charts: + """A container to hold and arrange multiple charts or cards. + + Displays items in a grid layout based on the number of columns set. + + Attributes: + cols: Number of columns in the grid (default 1). + """ + + def __init__(self, display, width: int = None, height: int = None): + """Create a new container. + + Args: + display: The display object (e.g., PicoGraphics). + width: Container width (defaults to display width). + height: Container height (defaults to display height). + + Raises: + ValueError: If display is None. + """ + if not display: + raise ValueError("Display object is required") + self._display = display + self.charts = [] + self.cols = 1 + self.width = width or display.get_bounds()[0] + self.height = height or display.get_bounds()[1] + self._background_colour = DEFAULT_COLORS['BACKGROUND'].copy() + self._title_colour = DEFAULT_COLORS['TITLE'].copy() + self._data_colour = DEFAULT_COLORS['DATA'].copy() + self._grid_colour = DEFAULT_COLORS['GRID'].copy() + self._border_colour = DEFAULT_COLORS['BORDER'].copy() + self._border_width = DEFAULT_SIZES['BORDER_WIDTH'] + + def add_chart(self, item) -> None: + """Add a chart or card to the container. + + Args: + item: Chart, Card, or ImageTile instance to add. + """ + if item not in self.charts: + self.charts.append(item) + item.background_colour = self._background_colour + item.title_colour = self._title_colour + item.data_colour = self._data_colour + item.grid_colour = self._grid_colour + item.border_colour = self._border_colour + item.border_width = self._border_width + + def update(self) -> None: + """Draw all items in the container. + + Arranges items in a grid based on cols and total items. + """ + try: + if not self.charts: + log_debug("No charts in container") + return + + rows = (len(self.charts) + self.cols - 1) // self.cols # Ceiling division + item_width = self.width // self.cols + item_height = self.height // rows + + for idx, item in enumerate(self.charts): + col = idx % self.cols + row = idx // self.cols + item.x = col * item_width + item.y = row * item_height + item.width = item_width + item.height = item_height item.update() + except Exception as e: + log_debug(f"Container update error: {e}") + @property - def background_colour(self): - return self.__background_colour + def background_colour(self) -> dict: + """Get the background color for all items.""" + return self._background_colour @background_colour.setter - def background_colour(self, value): - - self.__background_colour = value + def background_colour(self, value: dict) -> None: + """Set the background color for all items. + + Args: + value: Dict with 'red', 'green', 'blue' keys (0-255). + """ + self._background_colour = value for item in self.charts: item.background_colour = value - + @property - def grid_colour(self): - return self.__grid_colour + def grid_colour(self) -> dict: + """Get the grid color for all items.""" + return self._grid_colour @grid_colour.setter - def grid_colour(self, value): - - self.__grid_colour = value + def grid_colour(self, value: dict) -> None: + """Set the grid color for all items. + + Args: + value: Dict with 'red', 'green', 'blue' keys (0-255). + """ + self._grid_colour = value for item in self.charts: item.grid_colour = value - + @property - def data_colour(self): - return self.__data_colour + def data_colour(self) -> dict: + """Get the data color for all items.""" + return self._data_colour @data_colour.setter - def data_colour(self, value): - - self.__data_colour = value + def data_colour(self, value: dict) -> None: + """Set the data color for all items. + + Args: + value: Dict with 'red', 'green', 'blue' keys (0-255). + """ + self._data_colour = value for item in self.charts: item.data_colour = value - + @property - def title_colour(self): - return self.__title_colour + def title_colour(self) -> dict: + """Get the title color for all items.""" + return self._title_colour @title_colour.setter - def title_colour(self, value): - - self.__title_colour = value + def title_colour(self, value: dict) -> None: + """Set the title color for all items. + + Args: + value: Dict with 'red', 'green', 'blue' keys (0-255). + """ + self._title_colour = value for item in self.charts: item.title_colour = value - + @property - def border_colour(self): - return self.__border_colour + def border_colour(self) -> dict: + """Get the border color for all items.""" + return self._border_colour @border_colour.setter - def border_colour(self, value): - - self.__border_colour = value + def border_colour(self, value: dict) -> None: + """Set the border color for all items. + + Args: + value: Dict with 'red', 'green', 'blue' keys (0-255). + """ + self._border_colour = value for item in self.charts: item.border_colour = value + @property - def border_width(self): - return self.__border_width + def border_width(self) -> int: + """Get the border width for all items.""" + return self._border_width @border_width.setter - def border_width(self, value): - - self.__border_width = value + def border_width(self, value: int) -> None: + """Set the border width for all items. + + Args: + value: Width in pixels. + """ + self._border_width = value for item in self.charts: item.border_width = value \ No newline at end of file