Source code for wildewidgets.widgets.charts.chartjs

from __future__ import annotations

import math
import random
from typing import TYPE_CHECKING, Final, Literal

from django import template
from django.conf import settings

from wildewidgets.views import JSONDataView, WidgetInitKwargsMixin

from ..base import Widget

if TYPE_CHECKING:
    from collections.abc import Generator, Iterator


[docs]def float_range( start: float, stop: float, step: float = 1.0 ) -> Generator[float, None, None]: """ A generator function that yields a range of floating point numbers. This function behaves like the built-in `range` function, but for floating point numbers. It yields numbers starting from `start`, up to but not including `stop`, incrementing by `step`. Example: >>> list(float_range(0.0, 5.0, 1.0)) [0.0, 1.0, 2.0, 3.0, 4.0] >>> list(float_range(0.0, 5.0, 0.5)) [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0] Note: This function does not support negative steps or reverse ranges. Args: start: the starting value of the range. stop: the end value of the range (exclusive). step: the increment between each value in the range. Yields: float: the next value in the range. """ current = start while current < stop: yield current current += step
[docs]class CategoryChart(Widget, WidgetInitKwargsMixin, JSONDataView): """ Base class for Chart.js charts that display data in categories. This abstract class provides the foundation for creating various Chart.js visualizations that organize data into categories (like bar charts, pie charts, etc.). It handles dataset management, color assignment, and rendering. Note: This is an abstract base class. Subclasses should implement the necessary methods to create specific chart types. Example: .. code-block:: python from wildewidgets import CategoryChart class MyBarChart(CategoryChart): def __init__(self, **kwargs): super().__init__(chart_type="bar", **kwargs) self.set_categories(['A', 'B', 'C']) self.add_dataset([10, 20, 30], "Series 1") """ #: Default colors for the chart, used if no custom colors are set COLORS: Final[list[tuple[int, int, int]]] = [ (0, 59, 76), (0, 88, 80), (100, 75, 120), (123, 48, 62), (133, 152, 148), (157, 174, 136), (159, 146, 94), (242, 211, 131), (30, 152, 138), (115, 169, 80), ] #: Default grayscale colors for the chart, used if no custom colors are set GRAYS: Final[list[tuple[int, int, int]]] = [ (200, 200, 200), (229, 229, 229), (170, 169, 159), (118, 119, 123), (97, 98, 101), (175, 175, 175), (105, 107, 115), ] #: The Django template file to render the chart template_file: str = "wildewidgets/categorychart.html" #: Display legend for the chart, can be set in options legend: bool = False #: Position of the legend, can be set in options legend_position: Literal["top", "bottom", "left", "right"] = "top" #: Whether to use the color palette (True) or grayscale (False) color: bool = True def __init__(self, *args, **kwargs): self.chart_options = { "width": kwargs.get("width", "400px"), "height": kwargs.get("height", "400px"), "title": kwargs.get("title"), "legend": kwargs.get("legend", self.legend), "legend_position": kwargs.get("legend_position", self.legend_position), "chart_type": kwargs.get("chart_type"), "histogram": kwargs.get("histogram", False), "max": kwargs.get("max"), "thousands": kwargs.get("thousands", False), "histogram_max": kwargs.get("histogram_max"), "url": kwargs.get("url"), } self.chart_id = kwargs.get("chart_id") self.categories = None self.datasets = [] self.dataset_labels = [] self.color = kwargs.get("color", self.color) self.colors = [] if hasattr(settings, "CHARTJS_FONT_FAMILY"): self.chart_options["chartjs_font_family"] = settings.CHARTJS_FONT_FAMILY if hasattr(settings, "CHARTJS_TITLE_FONT_SIZE"): self.chart_options["chartjs_title_font_size"] = ( settings.CHARTJS_TITLE_FONT_SIZE ) if hasattr(settings, "CHARTJS_TITLE_FONT_STYLE"): self.chart_options["chartjs_title_font_style"] = ( settings.CHARTJS_TITLE_FONT_STYLE ) if hasattr(settings, "CHARTJS_TITLE_PADDING"): self.chart_options["chartjs_title_padding"] = settings.CHARTJS_TITLE_PADDING super().__init__(*args, **kwargs)
[docs] def set_categories(self, categories: list[str] | list[float]) -> None: """ Set the categories for the chart's x-axis. Args: categories: List of category labels to display on the x-axis """ self.categories = categories
[docs] def add_dataset( self, dataset: list[float] | list[int], label: str | None = None ) -> None: """ Add a dataset to the chart. Args: dataset: List of data values corresponding to each category label: Optional name for the dataset (shown in legend) """ self.datasets.append(dataset) self.dataset_labels.append(label)
[docs] def set_option(self, name: str, value: str | float | bool | None) -> None: """ Set a chart configuration option. Args: name: The option name value: The option value """ self.chart_options[name] = value
[docs] def set_color(self, color: bool) -> None: """ Enable or disable color mode. Args: color: If True, use the color palette; if False, use grayscale """ self.color = color
[docs] def set_colors(self, colors: list[tuple[int, int, int]]) -> None: """ Set a custom color palette for the chart. Args: colors: List of RGB color tuples to use for chart elements """ self.colors = colors
[docs] def get_content(self, **kwargs): # noqa: ARG002 """ Render the chart as HTML content. Returns: str: The rendered HTML for the chart """ chart_id = self.chart_id or str(random.randrange(0, 1000)) # noqa: S311 template_file = self.template_file context = self.get_context_data() if self.datasets else {"async": True} html_template = template.loader.get_template(template_file) context["options"] = self.chart_options context["name"] = f"chart_{chart_id}" context["wildewidgetclass"] = self.__class__.__name__ context["extra_data"] = self.get_encoded_extra_data() return html_template.render(context)
def __str__(self): """ Return the string representation of the chart. Returns: str: The rendered HTML for the chart """ return self.get_content()
[docs] def get_context_data(self, **kwargs): """ Get the context data for rendering the chart template. Returns: dict: The context data for the template """ context = super().get_context_data(**kwargs) self.load() context.update( { "labels": self.get_categories(), "datasets": self.get_dataset_configs(), } ) return context
[docs] def get_color_iterator(self) -> Iterator[tuple[int, int, int]]: """ Get an iterator over the chart's color palette. Returns: iterator: Iterator over RGB color tuples """ if self.colors: return iter(self.colors) if self.color: return iter(self.COLORS) return iter(self.GRAYS)
[docs] def get_dataset_options(self, index, color: tuple[int, int, int]): # noqa: ARG002 """ Get rendering options for a dataset. Args: index: The index of the dataset color: The RGB color tuple for the dataset Returns: dict: Dataset rendering options """ return { "backgroundColor": f"rgba({color[0]}, {color[1]}, {color[2]}, 0.5)", "borderColor": f"rgba({color[0]}, {color[1]}, {color[2]}, 1)", "borderWidth": 0.2, }
[docs] def get_dataset_configs(self) -> list[dict]: """ Get the complete configuration for all datasets. Returns: list[dict]: List of dataset configuration dictionaries Note: This method should be implemented by subclasses. """ return []
[docs] def get_categories(self) -> list[str]: """ Get the category labels for the chart. Returns: list[str]: List of category labels Raises: NotImplementedError: If not implemented by a subclass """ if self.categories: return self.categories msg = 'You should return a labels list. (i.e: ["January", ...])' raise NotImplementedError(msg)
[docs] def get_datasets(self) -> list[list[float]]: """ Get all datasets for the chart. Returns: list[list[float]]: List of datasets, each containing values for each category Raises: NotImplementedError: If not implemented by a subclass """ if self.datasets: return self.datasets msg = "You should return a data list list. (i.e: [[25, 34, 0, 1, 50], ...])." raise NotImplementedError(msg)
[docs] def get_dataset_labels(self) -> list[str]: """ Get labels for all datasets. Returns: list[str]: List of dataset labels """ return self.dataset_labels
[docs] def load(self) -> None: """ Load data into the chart. This method should be implemented by subclasses to load data from external sources or perform calculations before rendering. """
[docs]class DoughnutChart(CategoryChart): """ A doughnut chart implementation using Chart.js. This class creates a doughnut chart (a pie chart with a hole in the center) that displays data as segments of a circle. Each segment represents a proportion of the whole, making this chart ideal for showing composition. Example: .. code-block:: python chart = DoughnutChart(title="Browser Usage") chart.set_categories(['Chrome', 'Firefox', 'Safari', 'Edge', 'Other']) chart.add_dataset([65, 15, 10, 8, 2], "Usage Share") """ def __init__(self, *args, **kwargs) -> None: """ Initialize a doughnut chart. Args: *args: Positional arguments passed to the parent class **kwargs: Keyword arguments passed to the parent class chart_type: Set to "doughnut" by default if not specified """ if "chart_type" not in kwargs: kwargs["chart_type"] = "doughnut" super().__init__(*args, **kwargs)
[docs] def get_dataset_configs(self) -> list[dict]: """ Configure datasets for the doughnut chart. For doughnut charts, each data point gets its own color in the backgroundColor array. Returns: list[dict]: List containing a single dataset configuration """ datasets = [] color_generator = self.get_color_iterator() data = self.get_dataset() dataset = {"data": data} dataset["backgroundColor"] = [] for _ in range(len(data)): color = tuple(next(color_generator)) dataset["backgroundColor"].append( f"rgba({color[0]}, {color[1]}, {color[2]}, 0.5)" ) datasets.append(dataset) return datasets
[docs] def get_dataset(self): """ Get the primary dataset for the doughnut chart. Returns: list: The first dataset in the datasets list """ return self.datasets[0]
[docs]class PieChart(DoughnutChart): """ A pie chart implementation using Chart.js. This class creates a pie chart that displays data as segments of a circle. Each segment represents a proportion of the whole, making this chart ideal for showing composition. It extends DoughnutChart and has the same functionality, just with a different chart type. Example: .. code-block:: python from wildewidgets import PieChart chart = PieChart(title="Expenditure Breakdown") chart.set_categories( ['Housing', 'Food', 'Transport', 'Entertainment', 'Other'] ) chart.add_dataset([35, 25, 15, 15, 10], "Percentage") """ def __init__(self, *args, **kwargs): """ Initialize a pie chart. Args: *args: Positional arguments passed to the parent class **kwargs: Keyword arguments passed to the parent class Note: Automatically sets chart_type to "pie" """ kwargs["chart_type"] = "pie" super().__init__(*args, **kwargs)
[docs]class BarChart(CategoryChart): """ A bar chart implementation using Chart.js. This class creates a vertical bar chart where data is displayed as rectangular bars with heights proportional to their values. Bar charts are excellent for comparing quantities across different categories. Features: - Support for multiple datasets (grouped bars) - Optional stacking of bars - Horizontal orientation option - Money formatting option Example: .. code-block:: python from wildewidgets import BarChart chart = BarChart(title="Monthly Sales") chart.set_categories(['Jan', 'Feb', 'Mar', 'Apr', 'May']) chart.add_dataset([10000, 15000, 12000, 18000, 20000], "2022") chart.add_dataset([12000, 18000, 15000, 21000, 25000], "2023") """ def __init__(self, *args, **kwargs): """ Initialize a bar chart. Args: *args: Positional arguments passed to the parent class Keyword Args: **kwargs: Keyword arguments passed to the parent class chart_type: Set to "bar" by default if not specified money: Whether to format values as currency (default: False) stacked: Whether to stack multiple datasets (default: False) xAxes_name: Name for x-axis configuration (default: "xAxes") yAxes_name: Name for y-axis configuration (default: "yAxes") """ if "chart_type" not in kwargs: kwargs["chart_type"] = "bar" super().__init__(*args, **kwargs) self.set_option("money", kwargs.get("money", False)) self.set_option("stacked", kwargs.get("stacked", False)) self.set_option("xAxes_name", kwargs.get("xAxes_name", "xAxes")) self.set_option("yAxes_name", kwargs.get("yAxes_name", "yAxes"))
[docs] def set_stacked(self, stacked: bool) -> None: """ Enable or disable stacked mode for the bar chart. In stacked mode, bars from different datasets are stacked on top of each other instead of being displayed side by side. Args: stacked: If True, enable stacked mode; if False, disable it """ self.set_option("stacked", "true" if stacked else "false")
[docs] def set_horizontal(self, horizontal: bool) -> None: """ Set the orientation of the bar chart. Args: horizontal: If True, display bars horizontally; if False, display vertically """ if horizontal: self.chart_options["xAxes_name"] = "yAxes" self.chart_options["yAxes_name"] = "xAxes" self.chart_options["chart_type"] = "horizontalBar" else: self.chart_options["xAxes_name"] = "xAxes" self.chart_options["yAxes_name"] = "yAxes" self.chart_options["chart_type"] = "bar"
[docs] def get_dataset_options(self, index, color: tuple[int, int, int]): # noqa: ARG002 """ Get rendering options for a dataset in the bar chart. Args: index: The index of the dataset color: The RGB color tuple for the dataset Returns: dict: Dataset rendering options with appropriate styling """ default_opt: dict[str, str | float] = { "backgroundColor": f"rgba({color[0]}, {color[1]}, {color[2]}, 0.65)", } if not self.chart_options["histogram"]: default_opt["borderColor"] = f"rgba({color[0]}, {color[1]}, {color[2]}, 1)" default_opt["borderWidth"] = 0.2 return default_opt
[docs] def get_dataset_configs(self) -> list[dict]: """ Get the complete configuration for all datasets in the bar chart. This method prepares all datasets with appropriate colors and options. Returns: list[dict]: List of dataset configuration dictionaries """ datasets = [] color_generator = self.get_color_iterator() data = self.get_datasets() dataset_labels = self.get_dataset_labels() num = len(dataset_labels) for i, entry in enumerate(data): color = next(color_generator) dataset: dict[str, str | float | list[float]] = {"data": entry} dataset.update(self.get_dataset_options(i, color)) if i < num: dataset["label"] = dataset_labels[i] # series labels for Chart.js dataset["name"] = dataset_labels[i] # HighCharts may need this datasets.append(dataset) return datasets
[docs]class StackedBarChart(BarChart): """ A stacked bar chart implementation using Chart.js. This class creates a vertical bar chart where multiple datasets are stacked on top of each other for each category. This is useful for comparing total values across categories while also showing the composition of each total. Example: .. code-block:: python from wildewidgets import StackedBarChart chart = StackedBarChart(title="Revenue Breakdown") chart.set_categories(['Q1', 'Q2', 'Q3', 'Q4']) chart.add_dataset([50000, 60000, 55000, 75000], "Product A") chart.add_dataset([35000, 40000, 45000, 55000], "Product B") chart.add_dataset([15000, 20000, 25000, 30000], "Product C") """ def __init__(self, *args, **kwargs) -> None: """ Initialize a stacked bar chart. Args: *args: Positional arguments passed to the parent class **kwargs: Keyword arguments passed to the parent class Note: Automatically enables stacked mode during initialization. """ super().__init__(*args, **kwargs) self.set_stacked(True)
[docs]class HorizontalBarChart(BarChart): """ A horizontal bar chart implementation using Chart.js. This class creates a horizontal bar chart where data is displayed as rectangular bars with lengths proportional to their values. Horizontal bar charts are excellent for comparing quantities across different categories, especially when category labels are long. Example: .. code-block:: python from wildewidgets import HorizontalBarChart chart = HorizontalBarChart(title="Population by Country") chart.set_categories( ['United States', 'Indonesia', 'Brazil', 'Russia', 'Mexico'] ) chart.add_dataset( [331000000, 273500000, 211800000, 145500000, 130000000], "Population" ) """ def __init__(self, *args, **kwargs) -> None: """ Initialize a horizontal bar chart. Args: *args: Positional arguments passed to the parent class **kwargs: Keyword arguments passed to the parent class Note: Automatically sets horizontal orientation during initialization. """ super().__init__(*args, **kwargs) self.set_horizontal(True)
[docs]class HorizontalStackedBarChart(BarChart): """ A horizontal stacked bar chart implementation using Chart.js. This class creates a horizontal bar chart where multiple datasets are stacked on top of each other for each category. Useful for comparing parts of a whole across different categories. The chart is configured with both horizontal orientation and stacked mode automatically during initialization. Examples: .. code-block:: python from wildewidgets import HorizontalStackedBarChart chart = HorizontalStackedBarChart() chart.set_categories(['A', 'B', 'C']) chart.add_dataset([10, 20, 30], "Dataset 1") chart.add_dataset([5, 15, 25], "Dataset 2") """ def __init__(self, *args, **kwargs) -> None: """ Initialize a horizontal stacked bar chart. Args: *args: Positional arguments passed to the parent class **kwargs: Keyword arguments passed to the parent class Note: Automatically configures the chart as both horizontal and stacked. """ super().__init__(*args, **kwargs) self.set_horizontal(True) self.set_stacked(True)
[docs]class Histogram(BarChart): """ A vertical histogram chart for visualizing data distribution. This class creates a histogram that displays the distribution of numerical data by grouping values into bins. The height of each bar represents the frequency of values within that bin. To use this chart, call the `build` method with your raw data and the desired number of bins. The class will automatically: 1. Calculate appropriate bin ranges 2. Count values in each bin 3. Configure the chart with proper categories and dataset Examples: .. code-block:: python # Create a histogram with 10 bins for a list of data points chart = Histogram(title="Distribution of Values") chart.build([23.5, 24.1, 25.3, 26.2, 27.5, 28.1, 29.3, 30.2, 31.5], 10) """ def __init__(self, *args, **kwargs) -> None: """ Initialize a histogram chart. Args: *args: Positional arguments passed to the parent class **kwargs: Keyword arguments passed to the parent class histogram: Flag to enable histogram mode (set to True by default) """ if "histogram" not in kwargs: kwargs["histogram"] = True super().__init__(*args, **kwargs)
[docs] def build(self, data: list[float], bin_count: int) -> None: """ Process raw data and configure the histogram. This method handles all the calculations needed to convert raw data into a histogram representation: - Determines the range of the data - Calculates appropriate bin sizes and boundaries - Counts the frequency of values in each bin - Sets up the chart categories and dataset The method uses a robust algorithm that handles both positive and negative values, and attempts to create visually appealing bin boundaries. Args: data: List of numerical values to be plotted in the histogram bin_count: Number of bins to divide the data into Note: The method will set two special options on the chart: - "max": The upper bound of the last visible bin - "histogram_max": The absolute upper boundary of the data range """ num_min = min(data) num_max = max(data) num_range = num_max - num_min bin_chunk = num_range / bin_count bin_power = math.floor(math.log10(bin_chunk)) bin_chunk = math.ceil(bin_chunk / 10**bin_power) * 10**bin_power if num_min < 0: bin_min = math.ceil(math.fabs(num_min / bin_chunk)) * bin_chunk * -1 else: bin_min = math.floor(num_min / bin_chunk) * bin_chunk if num_max < 0: bin_max = math.floor(math.fabs(num_max / bin_chunk)) * bin_chunk * -1 else: bin_max = math.ceil(num_max / bin_chunk) * bin_chunk categories = list(float_range(bin_min, bin_max + bin_chunk, bin_chunk)) self.set_option("max", categories[-2]) self.set_option("histogram_max", categories[-1]) bins = [0] * bin_count for num in data: for i in range(len(categories) - 1): if num >= categories[i] and num < categories[i + 1]: if i < len(bins): bins[i] += 1 self.set_categories(categories) self.add_dataset(bins, "data")
[docs]class HorizontalHistogram(Histogram): """ A horizontal histogram chart for visualizing data distribution. This class extends the regular Histogram to display bars horizontally instead of vertically. This can be particularly useful when: - You have many bins that would appear too narrow in a vertical histogram - You want to display long category labels that are easier to read horizontally - You want to emphasize the comparison of frequencies across bins All the functionality of the regular Histogram is preserved, including the automatic calculation of bin ranges and frequencies. Examples: .. code-block:: python # Create a horizontal histogram with 8 bins chart = HorizontalHistogram(title="Age Distribution") chart.build([21, 22, 24, 25, 26, 28, 29, 31, 32, 33, 35, 37, 39, 45], 8) """ def __init__(self, *args, **kwargs): """ Initialize a horizontal histogram chart. Args: *args: Positional arguments passed to the parent class **kwargs: Keyword arguments passed to the parent class Note: Automatically configures the chart with horizontal orientation while preserving all histogram functionality. """ super().__init__(*args, **kwargs) self.set_horizontal(True)