/
Flipper
/
Marginal
Обзор
Документация
Войти
/
Flipper
/
Marginal
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
Безопасность
master
src/Canvas.py
609 строк
21 KB
Alex
Marginal v1.0.0 RELEASE
11 янв 2026, 18:02
11 янв 2026, 18:02
5e1eff8
Код
Авторство
О чём код?
from matplotlib import use use("Agg") import matplotlib.pyplot as plt from matplotlib import rcParams import matplotlib.patches as mpatches import os import json from utils import getBasePath class Canvas: """ Creates and manages matplotlib figures based on configuration dictionaries. Handles theming, multiple plot types, annotations, markers, and safe data binding. """ def __init__(self, backbone): """ Initialize Canvas with application backbone. Args: backbone (Backbone): Central application controller. """ self.backbone = backbone self.logger = self.backbone.logger self.styleDir = os.path.join(getBasePath(), "resources", "graphStyles") self.defaultStyle = os.path.join( getBasePath(), "local", "app_defaultGraphConfig.json" ) self.currentStyle = {} self.plotDefaults = {} self.loadStyle() def loadDefaultStyle(self): """ Load the default graph style configuration from the default JSON file. Returns: None """ try: with open(self.defaultStyle, "r") as configFile: self.currentStyle = json.load(configFile) self.applyStyle(self.currentStyle) except Exception as e: self.logger.log("Canvas", "Failed to load default style: {e}", e=e) plt.style.use("default") self.currentStyle = {} self.plotDefaults = {} def loadStyle(self, styleName: str = "", log: bool = True): """ Load and apply a named graph style from JSON file. Args: styleName (str): Name of the style file (without extension). log (bool): Whether to log successful loading. Returns: None """ if not styleName: styleName = self.backbone.getConfig("graphOptions")["style"] pathToStyle = os.path.join(self.styleDir, f"{styleName}.json") if not os.path.isfile(pathToStyle): self.logger.log( "Canvas", "Style file '{path}' not found. Using default style.", path=pathToStyle, ) self.loadDefaultStyle() return try: with open(pathToStyle, "r") as configFile: style = json.load(configFile) self.applyStyle(style) self.currentStyle = style if log: self.logger.log( "Canvas", "Successfully loaded '{styleName}' style.", True, styleName=styleName, ) except Exception as e: self.loadDefaultStyle() self.logger.log( "Canvas", "Failed to load '{styleName}' style: {e}", styleName=styleName, e=e, ) def applyStyle(self, style: dict): """ Apply matplotlib style and rcParams dynamically. Args: style (dict): Style configuration dictionary with 'style' and 'rcParams' keys. Returns: None """ if not style: return if "style" in style: plt.style.use(style["style"]) if "rcParams" in style: for key, value in style["rcParams"].items(): rcParams[key] = value self.plotDefaults = style.get("defaults", {}) def createPlot( self, plotConfig, data, results, plotName="plot", useThemeStyle=True, allowStyleOverwrite=False, ): """ Create a matplotlib figure based on plot configuration. Args: plotConfig (dict): Plot configuration definition. data (dict): Input data values. results (dict): Calculation result values. plotName (str): Logical name of the plot (for logging). useThemeStyle (bool): Apply current theme defaults if True. allowStyleOverwrite (bool): Allow config to override theme defaults. Returns: matplotlib.figure.Figure | None -> Generated figure or None on failure. """ try: plotType = plotConfig.get("type", "scatter").lower() title = plotConfig.get("title", "Plot") xlabel = plotConfig.get("xlabel", "") ylabel = plotConfig.get("ylabel", "") grid = plotConfig.get("grid", True) legend = plotConfig.get("legend", True) fig, ax = plt.subplots() # Apply or bypass theme styling if not useThemeStyle: plt.style.use("default") rcParams.update({ "figure.figsize": [6.4, 4.8], "figure.dpi": 100, }) plotTypeDefaults = {} else: plotTypeDefaults = self.plotDefaults.get(plotType, {}).copy() # Handle multiple series or single plot if "series" in plotConfig: for series in plotConfig["series"]: self._plotSeries( ax, series, data, results, plotType, plotTypeDefaults, allowStyleOverwrite, useThemeStyle, ) else: xData = self._getData(plotConfig.get("x_data"), data, results) yData = self._getData(plotConfig.get("y_data"), data, results) params = self._processStyleParams( plotTypeDefaults, plotConfig, plotType, allowStyleOverwrite, useThemeStyle, ) # Remove configuration keys that aren't plot parameters plotKeysToRemove = [ 'type', 'title', 'xlabel', 'ylabel', 'grid', 'legend', 'x_data', 'y_data', 'xlim', 'ylim', 'series', 'annotations', 'markers' ] for key in plotKeysToRemove: params.pop(key, None) # Create appropriate plot type if plotType == "scatter": ax.scatter(xData, yData, **params) elif plotType == "line": ax.plot(xData, yData, **params) elif plotType == "bar": ax.bar(xData, yData, **params) elif plotType == "histogram": ax.hist(xData, **params) else: self.logger.log( "Canvas", "Unknown plot type: {plotType}", plotType=plotType, ) return None # Set plot labels and formatting ax.set_title(title) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) if grid: ax.grid(True, alpha=0.3) if legend and ("series" in plotConfig or plotConfig.get("label")): ax.legend() # Apply axis limits if specified if "xlim" in plotConfig: ax.set_xlim(plotConfig["xlim"]) if "ylim" in plotConfig: ax.set_ylim(plotConfig["ylim"]) # Add markers and annotations if configured if "markers" in plotConfig: self._addMarkers(ax, plotConfig["markers"], xData, yData, plotType) if "annotations" in plotConfig: self._addAnnotations(ax, plotConfig["annotations"], xData, yData, plotType) plt.tight_layout() return fig except Exception as e: self.logger.log("Canvas", "Plot creation failed: {e}", e=e) return None def _processStyleParams( self, plotTypeDefaults, plotConfig, plotType, allowStyleOverwrite, useThemeStyle, ): """ Merge style defaults with plot configuration parameters. Args: plotTypeDefaults (dict): Theme defaults for specific plot type. plotConfig (dict): Plot or series configuration. plotType (str): Plot type identifier (scatter, line, bar, histogram). allowStyleOverwrite (bool): Allow config to override theme defaults. useThemeStyle (bool): Whether theme styling is active. Returns: dict -> Final matplotlib-compatible parameters. """ params = plotTypeDefaults.copy() # Convert parameter aliases to matplotlib-compatible names params = self._convertParamNames(params, plotType) # Apply plot config overrides based on settings if allowStyleOverwrite or not useThemeStyle: plotConfigParams = plotConfig.copy() plotConfigParams = self._convertParamNames(plotConfigParams, plotType) params.update(plotConfigParams) return params def _convertParamNames(self, params, plotType): """ Convert configuration parameter aliases to matplotlib-compatible parameter names. Args: params (dict): Parameter dictionary with alias keys. plotType (str): Plot type for context-specific conversions. Returns: dict -> Converted parameter dictionary. """ converted = params.copy() if plotType == "scatter": # Convert scatter plot aliases if "size" in converted: converted["s"] = converted.pop("size") if "color" in converted: converted["c"] = converted.pop("color") if "edgecolor" in converted: converted["edgecolors"] = converted.pop("edgecolor") if "linewidth" in converted and "edgecolors" in converted: converted["linewidths"] = converted.pop("linewidth") elif plotType == "line": # Convert line plot aliases if "size" in converted and "markersize" not in converted: converted["markersize"] = converted.pop("size") if "color" in converted: converted["c"] = converted.pop("color") # Note: bar and histogram plots use standard matplotlib parameter names # No conversion needed for these plot types return converted def _plotSeries( self, ax, seriesConfig, data, results, defaultType, plotTypeDefaults, allowStyleOverwrite, useThemeStyle, ): """ Plot a single data series within a multi-series plot. Args: ax (matplotlib.axes.Axes): Axes to plot on. seriesConfig (dict): Series configuration. data (dict): Input data values. results (dict): Calculation result values. defaultType (str): Fallback plot type if not specified in series. plotTypeDefaults (dict): Theme defaults for plot type. allowStyleOverwrite (bool): Allow series to override theme defaults. useThemeStyle (bool): Use theme styling. Returns: None """ xData = self._getData(seriesConfig.get("x_data"), data, results) yData = self._getData(seriesConfig.get("y_data"), data, results) plotType = seriesConfig.get("type", defaultType) label = seriesConfig.get("label") params = self._processStyleParams( plotTypeDefaults, seriesConfig, plotType, allowStyleOverwrite, useThemeStyle, ) if label: params["label"] = label # Remove series configuration keys that aren't plot parameters seriesKeysToRemove = ['type', 'x_data', 'y_data', 'label'] for key in seriesKeysToRemove: params.pop(key, None) # Create the series plot if plotType == "scatter": ax.scatter(xData, yData, **params) elif plotType == "line": ax.plot(xData, yData, **params) def _getData(self, dataRef, inputData, results): """ Resolve data reference from inputs, results, or Python expressions. Supports: - Direct list values - References to inputData or results dictionaries - Python list expressions (e.g., "[x**2 for x in range(10)]") - Simple scalar values Args: dataRef (any): Data reference string, list, or expression. inputData (dict): Input values from user. results (dict): Calculated result values. Returns: list -> Resolved data values (always returns a list). """ if dataRef is None: return [] # Direct list value if isinstance(dataRef, list): return dataRef # Evaluate Python list expression (e.g., "[x*2 for x in myData]") if isinstance(dataRef, str) and dataRef.startswith("[") and dataRef.endswith("]"): try: # Create safe evaluation environment with only essential functions safeEnv = { **inputData, **results, "len": len, "range": range, "list": list, "zip": zip, } return eval(dataRef, {"__builtins__": {}}, safeEnv) except Exception: self.logger.log( "Canvas", "Failed to evaluate expression: {dataRef}.", True, dataRef=dataRef, ) return [] # Reference to input data if dataRef in inputData: value = inputData[dataRef] return value if isinstance(value, list) else [value] # Reference to calculation results if dataRef in results: value = results[dataRef] return value if isinstance(value, list) else [value] # Try evaluating as a simple expression (e.g., "sin(x)") try: safeEnv = { **inputData, **results, "len": len, "range": range, "list": list, "zip": zip, "mean": lambda x: sum(x) / len(x) if x else 0, } result = eval(dataRef, {"__builtins__": {}}, safeEnv) return result if isinstance(result, list) else [result] except Exception as e: self.logger.log( "Canvas", "Failed to get data for '{dataRef}': {e}", True, dataRef=dataRef, e=e, ) return [] def _addMarkers(self, ax, markersConfig, xData, yData, plotType): """ Add visual markers to specific data points on the plot. Args: ax (matplotlib.axes.Axes): Axes to add markers to. markersConfig (list): List of marker configuration dictionaries. xData (list): X-axis data values. yData (list): Y-axis data values. plotType (str): Plot type for marker styling. Returns: None """ for marker in markersConfig: try: idx = marker.get("index", 0) # Ensure index is within bounds if idx >= len(xData) or idx >= len(yData): continue xVal = xData[idx] yVal = yData[idx] # Apply marker based on plot type if plotType == "scatter": ax.scatter( [xVal], [yVal], marker=marker.get("marker", "o"), color=marker.get("color", "red"), edgecolor=marker.get("edgecolor", "black"), linewidth=marker.get("linewidth", 2), s=marker.get("size", 100), zorder=10, ) else: ax.plot( xVal, yVal, marker=marker.get("marker", "o"), color=marker.get("color", "red"), markersize=marker.get("size", 12), markeredgecolor=marker.get("edgecolor", "black"), markeredgewidth=marker.get("linewidth", 2), zorder=10, ) # Add to legend if label specified if "label" in marker: patch = mpatches.Patch( color=marker.get("color", "red"), label=marker["label"], ) handles, labels = ax.get_legend_handles_labels() ax.legend(handles + [patch]) except Exception as e: self.logger.log("Canvas", "Failed to add marker: {e}", e=e) def _addAnnotations(self, ax, annotationsConfig, xData, yData, plotType): """ Add text annotations with optional "legs" (lines connecting to axes). Args: ax (matplotlib.axes.Axes): Axes to add annotations to. annotationsConfig (list): List of annotation configuration dictionaries. xData (list): X-axis data values. yData (list): Y-axis data values. plotType (str): Plot type (unused but kept for consistency). Returns: None """ for ann in annotationsConfig: try: idx = ann.get("index", 0) # Ensure index is within bounds if idx >= len(xData) or idx >= len(yData): continue xVal = xData[idx] yVal = yData[idx] # Add "legs" (lines to axes) if requested if "legs" in ann and ann["legs"]: legsConfig = ann["legs"] legColor = legsConfig.get("color", "gray") legStyle = legsConfig.get("linestyle", "--") legWidth = legsConfig.get("linewidth", 1) legAlpha = legsConfig.get("alpha", 0.5) # Draw vertical line to x-axis if legsConfig.get("xaxis", False): ylim = ax.get_ylim() ymax = ylim[1] if ylim[1] != 0 else 1 ax.axvline(x=xVal, ymin=0, ymax=yVal / ymax, color=legColor, linestyle=legStyle, linewidth=legWidth, alpha=legAlpha, zorder=5) # Draw horizontal line to y-axis if legsConfig.get("yaxis", False): xlim = ax.get_xlim() xmax = xlim[1] if xlim[1] != 0 else 1 ax.axhline(y=yVal, xmin=0, xmax=xVal / xmax, color=legColor, linestyle=legStyle, linewidth=legWidth, alpha=legAlpha, zorder=5) # Configure annotation parameters annotationParams = { "xy": (xVal, yVal), "xytext": ann.get("xytext", (20, 20)), "textcoords": "offset points", "ha": ann.get("ha", "center"), "va": ann.get("va", "center"), "fontsize": ann.get("fontsize", 10), "color": ann.get("color", "black"), "zorder": 10 } # Add optional arrow properties if "arrowprops" in ann: annotationParams["arrowprops"] = ann["arrowprops"] # Add optional bounding box if "bbox" in ann: annotationParams["bbox"] = ann["bbox"] ax.annotate(ann.get("text", ""), **annotationParams) except Exception as e: self.logger.log("Canvas", "Failed to add annotation: {e}", e=e) def closeFig(self, fig): """ Close a matplotlib figure to free memory. Args: fig (matplotlib.figure.Figure): Figure to close. Returns: None """ plt.close(fig) def resetToDefaultStyle(self): """ Reset plotting style to default configuration. Returns: None """ self.loadDefaultStyle()