Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions plots/altair/arc/pie-basic/default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""
pie-basic: Basic Pie Chart
Library: altair
"""

import altair as alt
import pandas as pd


# PyPlots.ai default color palette
PYPLOTS_COLORS = ["#306998", "#FFD43B", "#DC2626", "#059669", "#8B5CF6", "#F97316"]


def create_plot(
data: pd.DataFrame,
category: str,
value: str,
*,
title: str | None = None,
colors: list[str] | None = None,
startangle: float = 90,
show_labels: bool = True,
label_format: str = ".1%",
legend: bool = True,
legend_loc: str = "right",
inner_radius: float = 0,
outer_radius: float = 150,
**kwargs,
) -> alt.Chart:
"""
Create a basic pie chart visualizing proportions of categorical data.

A fundamental pie chart where each slice represents a category's share of the whole,
ideal for showing composition and distribution across a small number of categories.

Args:
data: Input DataFrame containing the data to plot.
category: Column name for category labels (slice names).
value: Column name for numeric values (slice sizes).
title: Plot title. Defaults to None.
colors: Custom color palette for slices. Defaults to PyPlots.ai palette.
startangle: Starting angle for first slice in degrees. Defaults to 90.
show_labels: Whether to show percentage labels on slices. Defaults to True.
label_format: Format string for percentage labels. Defaults to ".1%".
legend: Whether to display legend. Defaults to True.
legend_loc: Legend location ('right', 'left', 'top', 'bottom'). Defaults to 'right'.
inner_radius: Inner radius for donut style (0 for solid pie). Defaults to 0.
outer_radius: Outer radius of the pie. Defaults to 150.
**kwargs: Additional parameters.

Returns:
Altair Chart object.

Raises:
ValueError: If data is empty or values contain negative numbers.
KeyError: If required columns are not found in data.

Example:
>>> data = pd.DataFrame({
... 'category': ['Product A', 'Product B', 'Product C'],
... 'value': [35, 25, 40]
... })
>>> chart = create_plot(data, 'category', 'value', title='Market Share')
"""
# Input validation
if data.empty:
raise ValueError("Data cannot be empty")

for col in [category, value]:
if col not in data.columns:
available = ", ".join(data.columns)
raise KeyError(f"Column '{col}' not found. Available: {available}")

# Validate non-negative values
if (data[value] < 0).any():
raise ValueError("Pie chart values must be non-negative")

# Handle case where all values are zero
total = data[value].sum()
if total == 0:
raise ValueError("Sum of values cannot be zero")

# Use custom colors or default palette
color_palette = colors if colors is not None else PYPLOTS_COLORS

# Calculate the starting angle in radians (Altair uses radians, offset from 12 o'clock)
# Altair's theta starts from 3 o'clock (0 degrees), so we need to adjust
# To start from 12 o'clock (90 degrees from 3 o'clock), we use theta2Offset
start_offset = (startangle - 90) * 3.14159 / 180
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded value 3.14159 should use math.pi for better precision and clarity. Import math and use math.pi instead of the magic number.

Copilot uses AI. Check for mistakes.

# Create base chart with arc mark
base = alt.Chart(data).encode(
theta=alt.Theta(f"{value}:Q", stack=True),
color=alt.Color(
f"{category}:N",
scale=alt.Scale(range=color_palette),
legend=alt.Legend(title=category, orient=legend_loc, labelFontSize=16, titleFontSize=16)
if legend
else None,
),
tooltip=[alt.Tooltip(f"{category}:N", title="Category"), alt.Tooltip(f"{value}:Q", title="Value")],
)

# Create the pie/arc chart
pie = base.mark_arc(
innerRadius=inner_radius,
outerRadius=outer_radius,
stroke="#ffffff",
strokeWidth=2,
theta2Offset=start_offset,
thetaOffset=start_offset,
)

# Add percentage labels if requested
if show_labels:
# Calculate percentage for labels
data_with_pct = data.copy()
data_with_pct["_percentage"] = data_with_pct[value] / total

# Create text labels positioned at the middle of each arc
text = (
alt.Chart(data_with_pct)
.mark_text(radius=outer_radius * 0.7, fontSize=14, fontWeight="bold", color="#FFFFFF")
.encode(theta=alt.Theta(f"{value}:Q", stack=True), text=alt.Text("_percentage:Q", format=label_format))
.transform_calculate(theta2Offset=str(start_offset), thetaOffset=str(start_offset))
)

# Layer pie and text
chart = alt.layer(pie, text)
else:
chart = pie

# Set chart dimensions and title
chart = chart.properties(width=400, height=400)

if title is not None:
chart = chart.properties(title=alt.TitleParams(text=title, fontSize=20, anchor="middle", fontWeight=600))

# Configure chart appearance
chart = chart.configure_view(strokeWidth=0).configure_legend(
labelFontSize=16, titleFontSize=16, symbolSize=200, padding=10
)

return chart


if __name__ == "__main__":
# Sample data for testing
sample_data = pd.DataFrame(
{"category": ["Product A", "Product B", "Product C", "Product D", "Other"], "value": [35, 25, 20, 15, 5]}
)

# Create plot
fig = create_plot(sample_data, "category", "value", title="Market Share Distribution")

# Save
fig.save("plot.png", scale_factor=2.0)
print("Plot saved to plot.png")
221 changes: 221 additions & 0 deletions plots/bokeh/custom/pie-basic/default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"""
pie-basic: Basic Pie Chart
Library: bokeh
"""

import math
from typing import TYPE_CHECKING

import pandas as pd
from bokeh.models import ColumnDataSource, Label, Legend, LegendItem
from bokeh.plotting import figure


if TYPE_CHECKING:
from bokeh.plotting import figure as Figure

# PyPlots.ai style colors
PYPLOTS_COLORS = [
"#306998", # Python Blue
"#FFD43B", # Python Yellow
"#DC2626", # Signal Red
"#059669", # Teal Green
"#8B5CF6", # Violet
"#F97316", # Orange
]


def create_plot(
data: pd.DataFrame,
category: str,
value: str,
title: str | None = None,
colors: list[str] | None = None,
startangle: float = 90,
legend: bool = True,
legend_loc: str = "right",
**kwargs,
) -> "Figure":
"""
Create a basic pie chart using Bokeh wedge glyphs.

Bokeh does not have a native pie chart method, so this implementation
uses wedge glyphs to construct the pie chart manually.

Args:
data: Input DataFrame containing category and value columns
category: Column name for category labels (slice names)
value: Column name for numeric values (slice sizes)
title: Plot title (optional)
colors: Custom color palette for slices (defaults to PyPlots colors)
startangle: Starting angle for first slice in degrees (default: 90)
legend: Whether to display legend (default: True)
legend_loc: Legend location - 'right', 'left', 'above', 'below' (default: 'right')
**kwargs: Additional parameters passed to figure

Returns:
Bokeh figure object

Raises:
ValueError: If data is empty or values are all zero/negative
KeyError: If required columns not found in data

Example:
>>> data = pd.DataFrame({
... 'category': ['A', 'B', 'C'],
... 'value': [30, 50, 20]
... })
>>> fig = create_plot(data, 'category', 'value', title='Distribution')
"""
# Input validation
if data.empty:
raise ValueError("Data cannot be empty")

for col in [category, value]:
if col not in data.columns:
available = ", ".join(data.columns)
raise KeyError(f"Column '{col}' not found. Available: {available}")

# Validate numeric values
if not pd.api.types.is_numeric_dtype(data[value]):
raise ValueError(f"Column '{value}' must contain numeric values")

if (data[value] < 0).any():
raise ValueError("Pie chart values must be non-negative")

total = data[value].sum()
if total == 0:
raise ValueError("Sum of values cannot be zero")

# Prepare data
plot_data = data.copy()
plot_data["angle"] = plot_data[value] / total * 2 * math.pi
plot_data["percentage"] = plot_data[value] / total * 100

# Calculate cumulative angles for wedge positioning
plot_data["end_angle"] = plot_data["angle"].cumsum()
plot_data["start_angle"] = plot_data["end_angle"] - plot_data["angle"]

# Apply start angle offset (convert degrees to radians, adjust for Bokeh's coordinate system)
start_rad = math.radians(startangle - 90)
plot_data["start_angle"] = plot_data["start_angle"] + start_rad
plot_data["end_angle"] = plot_data["end_angle"] + start_rad

# Assign colors
if colors is None:
colors = PYPLOTS_COLORS
# Cycle through colors if more categories than colors
num_categories = len(plot_data)
plot_data["color"] = [colors[i % len(colors)] for i in range(num_categories)]

# Create ColumnDataSource
source = ColumnDataSource(plot_data)

# Create figure - use range to ensure circular aspect ratio
# Set frame dimensions to maintain 16:9 overall but circular pie
fig_width = kwargs.get("width", 1600)
fig_height = kwargs.get("height", 900)

p = figure(
width=fig_width,
height=fig_height,
title=title,
tools="hover",
tooltips=[(category.capitalize(), f"@{category}"), ("Value", f"@{value}"), ("Percentage", "@percentage{0.1}%")],
x_range=(-1.2, 2.0 if legend else 1.2),
y_range=(-1.2, 1.2),
)

# Draw wedges (pie slices)
renderers = p.wedge(
x=0,
y=0,
radius=0.9,
start_angle="start_angle",
end_angle="end_angle",
line_color="white",
line_width=2,
fill_color="color",
source=source,
)

# Add percentage labels inside slices
for _, row in plot_data.iterrows():
# Calculate label position at middle of wedge, 60% from center
mid_angle = (row["start_angle"] + row["end_angle"]) / 2
label_radius = 0.55

x = label_radius * math.cos(mid_angle)
y = label_radius * math.sin(mid_angle)

# Only show percentage label if slice is large enough
if row["percentage"] >= 5:
label = Label(
x=x,
y=y,
text=f"{row['percentage']:.1f}%",
text_font_size="14pt",
text_align="center",
text_baseline="middle",
text_color="white" if row["percentage"] >= 10 else "black",
)
p.add_layout(label)

# Configure legend
if legend:
legend_items = []
for i, cat in enumerate(plot_data[category]):
legend_items.append(LegendItem(label=str(cat), renderers=[renderers], index=i))

leg = Legend(
items=legend_items,
location="center",
label_text_font_size="16pt",
background_fill_color="white",
background_fill_alpha=1.0,
border_line_color="black",
border_line_width=1,
)

p.add_layout(leg, legend_loc)

# Style configuration
p.axis.visible = False
p.grid.visible = False
p.outline_line_color = None

# Title styling
if title:
p.title.text_font_size = "20pt"
p.title.align = "center"

# Background
p.background_fill_color = "white"

return p


if __name__ == "__main__":
# Sample data for testing
sample_data = pd.DataFrame(
{"category": ["Product A", "Product B", "Product C", "Product D", "Other"], "value": [35, 25, 20, 15, 5]}
)

# Create plot
fig = create_plot(sample_data, "category", "value", title="Market Share Distribution")

# Save - try PNG first, fall back to HTML if selenium not available
try:
from bokeh.io import export_png

export_png(fig, filename="plot.png")
print("Plot saved to plot.png")
except RuntimeError as e:
if "selenium" in str(e).lower():
from bokeh.io import output_file, save

output_file("plot.html")
save(fig)
print("Plot saved to plot.html (selenium not available for PNG export)")
else:
raise
Loading
Loading