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)