From e565f9fd06ceb565131dced4fdde2ac0574315d0 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 30 Jul 2025 15:44:40 -0400
Subject: [PATCH 01/10] first pass
---
docs/_quarto.yml | 1 +
gt_extras/__init__.py | 2 +
gt_extras/plotting.py | 234 +++++++++++++++++++++++++++++++++++++++++-
3 files changed, 235 insertions(+), 2 deletions(-)
diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index f3f7c44c..26e46114 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -53,6 +53,7 @@ quartodoc:
- gt_plt_conf_int
- gt_plt_dot
- gt_plt_dumbbell
+ - gt_plt_pie
- gt_plt_summary
- gt_plt_winloss
diff --git a/gt_extras/__init__.py b/gt_extras/__init__.py
index e1159e24..7f98bb15 100644
--- a/gt_extras/__init__.py
+++ b/gt_extras/__init__.py
@@ -18,6 +18,7 @@
gt_plt_conf_int,
gt_plt_dot,
gt_plt_dumbbell,
+ gt_plt_pie,
gt_plt_winloss,
)
from .styling import gt_add_divider
@@ -56,6 +57,7 @@
"gt_plt_dot",
"gt_plt_conf_int",
"gt_plt_dumbbell",
+ "gt_plt_pie",
"gt_plt_winloss",
"gt_plt_bar_stack",
"with_hyperlink",
diff --git a/gt_extras/plotting.py b/gt_extras/plotting.py
index 0dfe228b..c0bd380d 100644
--- a/gt_extras/plotting.py
+++ b/gt_extras/plotting.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import math
import warnings
from typing import TYPE_CHECKING, Literal
@@ -29,6 +30,7 @@
"gt_plt_conf_int",
"gt_plt_dot",
"gt_plt_dumbbell",
+ "gt_plt_pie",
"gt_plt_winloss",
]
@@ -1209,6 +1211,234 @@ def _make_dumbbell_svg(
return res
+def gt_plt_pie(
+ gt: GT,
+ columns: SelectExpr = None,
+ fill: str = "purple",
+ size: float = 30,
+ stroke_color: str | None = "white",
+ stroke_width: float = 1,
+ show_labels: bool = False,
+ label_color: str = "black",
+ domain: list[int] | list[float] | None = None,
+ keep_columns: bool = False,
+) -> GT:
+ """
+ Create pie charts in `GT` cells.
+
+ The `gt_plt_pie()` function takes an existing `GT` object and adds pie charts to
+ specified columns. Each cell value is represented as a portion of a full pie chart,
+ with the chart size proportional to the cell's numeric value relative to the column's
+ maximum value. The maximum value in the column will display as a full circle.
+
+ Parameters
+ ----------
+ gt
+ A `GT` object to modify.
+
+ columns
+ The columns to target. Can be a single column or a list of columns (by name or index).
+ If `None`, the pie chart is applied to all numeric columns.
+
+ fill
+ The fill color for the pie chart segments.
+
+ size
+ The diameter of the pie chart in pixels.
+
+ stroke_color
+ The color of the border around the pie chart. The default is white, but if
+ `None` is passed, no stroke will be drawn.
+
+ stroke_width
+ The width of the border stroke in pixels.
+
+ show_labels
+ Whether or not to show labels on the pie charts.
+
+ label_color
+ The color of text labels on the pie charts (when `show_labels` is `True`).
+
+ domain
+ The domain of values to use for scaling. This can be a list of floats or integers.
+ If `None`, the domain is automatically set to `[0, max(column_values)]`.
+
+ keep_columns
+ Whether to keep the original column values. If this flag is `True`, the plotted values will
+ be duplicated into a new column with the string " plot" appended to the end of the column
+ name. See [`gt_duplicate_column()`](https://posit-dev.github.io/gt-extras/reference/gt_duplicate_column)
+ for more details.
+
+ Returns
+ -------
+ GT
+ A `GT` object with pie charts added to the specified columns.
+
+ Examples
+ --------
+
+ ```{python}
+ from great_tables import GT
+ from great_tables.data import gtcars
+ import gt_extras as gte
+
+ gtcars_mini = gtcars.loc[
+ 9:17,
+ ["model", "mfr", "year", "hp", "hp_rpm", "trq", "trq_rpm", "mpg_c", "mpg_h"]
+ ]
+
+ gt = (
+ GT(gtcars_mini, rowname_col="model")
+ .tab_stubhead(label="Car")
+ .cols_align("center")
+ .cols_align("left", columns="mfr")
+ )
+
+ gt.pipe(
+ gte.gt_plt_pie,
+ columns=["hp", "hp_rpm", "trq", "trq_rpm", "mpg_c", "mpg_h"],
+ size=40,
+ fill="steelblue"
+ )
+ ```
+
+ Note
+ --------
+ Each column's pie charts are scaled independently based on that column's min/max values.
+ A value equal to the column maximum will display as a full circle (360 degrees).
+ """
+
+ # Allow the user to hide the stroke
+ if stroke_color is None:
+ stroke_color = "transparent"
+ stroke_width = 0
+
+ def _make_pie_svg(
+ scaled_val: float,
+ original_val: int | float,
+ fill: str,
+ size: float,
+ stroke_color: str,
+ stroke_width: float,
+ show_labels: bool,
+ label_color: str,
+ ) -> str:
+ if is_na(gt._tbl_data, original_val) or scaled_val <= 0:
+ return f'
'
+
+ radius = size / 2
+ center_x = center_y = radius
+
+ # Calculate the angle in radians (0 to 2π)
+ angle = scaled_val * 2 * 3.14159265359 # 2π
+
+ elements = []
+
+ if scaled_val >= 1.0:
+ # Full circle
+ circle = Circle(
+ cx=center_x,
+ cy=center_y,
+ r=radius - stroke_width / 2,
+ fill=fill,
+ stroke=stroke_color,
+ stroke_width=stroke_width,
+ )
+ elements.append(circle)
+
+ # Add label if requested
+ if show_labels and not is_na(gt._tbl_data, original_val):
+ label_text = str(original_val)
+ text = Text(
+ text=label_text,
+ x=center_x,
+ y=center_y,
+ fill=label_color,
+ font_size=size * 0.2,
+ text_anchor="middle",
+ dominant_baseline="central",
+ )
+ elements.append(text)
+
+ svg = SVG(width=size, height=size, elements=elements)
+ return f'
{svg.as_str()}
'
+ else:
+ # Partial pie chart using path
+ # Start at the top (12 o'clock position)
+ start_x = center_x
+ start_y = stroke_width / 2
+
+ # Calculate end point
+ end_x = center_x + (radius - stroke_width / 2) * math.sin(angle)
+ end_y = center_y - (radius - stroke_width / 2) * math.cos(angle)
+
+ # Determine if we need a large arc (> 180 degrees)
+ large_arc = 1 if angle > math.pi else 0
+
+ # Create the path for the pie slice
+ path_data = f"M {center_x} {center_y} L {start_x} {start_y} A {radius - stroke_width / 2} {radius - stroke_width / 2} 0 {large_arc} 1 {end_x} {end_y} Z"
+
+ # For partial pies, we need to manually construct the SVG with the path
+ svg_start = f'"
+ return (
+ f'
{svg_start}{svg_content}{svg_end}
'
+ )
+
+ # Get names of columns
+ columns_resolved = resolve_cols_c(data=gt, expr=columns)
+
+ res = gt
+ for column in columns_resolved:
+ # Validate this is a single column and get values
+ col_name, col_vals = _validate_and_get_single_column(
+ gt,
+ column,
+ )
+
+ scaled_vals = _scale_numeric_column(
+ res._tbl_data,
+ col_name,
+ col_vals,
+ domain,
+ )
+
+ # The location of the plot column will be right after the original column
+ if keep_columns:
+ res = gt_duplicate_column(
+ res,
+ col_name,
+ after=col_name,
+ append_text=" plot",
+ )
+ col_name = col_name + " plot"
+
+ # Apply the scaled value for each row, so the pie is proportional
+ for i, scaled_val in enumerate(scaled_vals):
+ res = res.fmt(
+ lambda original_val, scaled_val=scaled_val: _make_pie_svg(
+ original_val=original_val,
+ scaled_val=scaled_val,
+ fill=fill,
+ size=size,
+ stroke_color=stroke_color,
+ stroke_width=stroke_width,
+ show_labels=show_labels,
+ label_color=label_color,
+ ),
+ columns=col_name,
+ rows=[i],
+ )
+
+ return res
+
+
def gt_plt_winloss(
gt: GT,
column: SelectExpr,
@@ -1869,7 +2099,7 @@ def _is_effective_int(val) -> bool:
# Helper function to make the individual bars
- def _make_bar_pct_html(
+ def _make_bar_pct_svg(
# original_val: int | float,
scaled_val: int | float,
height: int,
@@ -1949,7 +2179,7 @@ def _make_bar_pct_html(
return f'
{canvas.as_str()}
'
def _make_bar_pct(scaled_val: int) -> str:
- return _make_bar_pct_html(
+ return _make_bar_pct_svg(
# original_val=original_val,
scaled_val=scaled_val,
height=height,
From f356b765058da0ec5aa1075bfe3b7bd5a25e36d8 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 30 Jul 2025 16:40:48 -0400
Subject: [PATCH 02/10] writing out path with svg library - has the issue of
white interior, should be transparent
---
gt_extras/plotting.py | 118 ++++++++++++++++++++++++++++++++----------
1 file changed, 92 insertions(+), 26 deletions(-)
diff --git a/gt_extras/plotting.py b/gt_extras/plotting.py
index c0bd380d..ac76eae3 100644
--- a/gt_extras/plotting.py
+++ b/gt_extras/plotting.py
@@ -12,7 +12,19 @@
from great_tables._locations import resolve_cols_c
from great_tables._tbl_data import SelectExpr, is_na
from scipy.stats import sem, t, tmean
-from svg import SVG, Circle, Length, Line, Rect, Text
+from svg import (
+ SVG,
+ Arc,
+ Circle,
+ ClosePath,
+ Length,
+ Line,
+ LineTo,
+ MoveTo,
+ Path,
+ Rect,
+ Text,
+)
from gt_extras import gt_duplicate_column
from gt_extras._utils_color import _get_discrete_colors_from_palette
@@ -1328,15 +1340,17 @@ def _make_pie_svg(
radius = size / 2
center_x = center_y = radius
+ # Inner radius for donut shape (30% of outer radius)
+ inner_radius = radius * 0.3
# Calculate the angle in radians (0 to 2π)
- angle = scaled_val * 2 * 3.14159265359 # 2π
+ angle = scaled_val * 2 * math.pi
elements = []
if scaled_val >= 1.0:
- # Full circle
- circle = Circle(
+ # Full donut ring using two circles
+ outer_circle = Circle(
cx=center_x,
cy=center_y,
r=radius - stroke_width / 2,
@@ -1344,7 +1358,15 @@ def _make_pie_svg(
stroke=stroke_color,
stroke_width=stroke_width,
)
- elements.append(circle)
+ inner_circle = Circle(
+ cx=center_x,
+ cy=center_y,
+ r=inner_radius,
+ fill="white", # This will create the hole
+ stroke="none",
+ )
+ elements.append(outer_circle)
+ elements.append(inner_circle)
# Add label if requested
if show_labels and not is_na(gt._tbl_data, original_val):
@@ -1363,33 +1385,77 @@ def _make_pie_svg(
svg = SVG(width=size, height=size, elements=elements)
return f'
{svg.as_str()}
'
else:
- # Partial pie chart using path
- # Start at the top (12 o'clock position)
- start_x = center_x
- start_y = stroke_width / 2
-
- # Calculate end point
- end_x = center_x + (radius - stroke_width / 2) * math.sin(angle)
- end_y = center_y - (radius - stroke_width / 2) * math.cos(angle)
+ # Partial donut using path
+ # Outer arc start/end points
+ outer_start_x = center_x
+ outer_start_y = stroke_width / 2
+ outer_end_x = center_x + (radius - stroke_width / 2) * math.sin(angle)
+ outer_end_y = center_y - (radius - stroke_width / 2) * math.cos(angle)
+
+ # Inner arc start/end points
+ inner_start_x = center_x
+ inner_start_y = center_y - inner_radius
+ inner_end_x = center_x + inner_radius * math.sin(angle)
+ inner_end_y = center_y - inner_radius * math.cos(angle)
# Determine if we need a large arc (> 180 degrees)
- large_arc = 1 if angle > math.pi else 0
-
- # Create the path for the pie slice
- path_data = f"M {center_x} {center_y} L {start_x} {start_y} A {radius - stroke_width / 2} {radius - stroke_width / 2} 0 {large_arc} 1 {end_x} {end_y} Z"
-
- # For partial pies, we need to manually construct the SVG with the path
- svg_start = f'"
- return (
- f'
'
# Get names of columns
columns_resolved = resolve_cols_c(data=gt, expr=columns)
From d56c25e5c7b412c3b715feed64090d059b75813b Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 30 Jul 2025 17:09:15 -0400
Subject: [PATCH 04/10] redundant comment, move label
---
gt_extras/plotting.py | 27 ++++++++-------------------
1 file changed, 8 insertions(+), 19 deletions(-)
diff --git a/gt_extras/plotting.py b/gt_extras/plotting.py
index 6bf15511..32996c73 100644
--- a/gt_extras/plotting.py
+++ b/gt_extras/plotting.py
@@ -1343,11 +1343,9 @@ def _make_pie_svg(
radius = size / 2
center_x = center_y = radius
- # Inner radius for donut shape (30% of outer radius)
- inner_radius = radius * 0.3
+ inner_radius = radius * 0.4
# Calculate the angle in radians (0 to 2π)
- # Clamp to maximum of 2π for full circle
angle = min(scaled_val * 2 * math.pi, 2 * math.pi)
# Outer arc start/end points
@@ -1366,9 +1364,7 @@ def _make_pie_svg(
large_arc = angle > math.pi
# For full circle (angle >= 2π), we need special handling to avoid degenerate arcs
- if (
- angle >= 2 * math.pi - 0.001
- ): # Use small epsilon to handle floating point precision
+ if angle >= 2 * math.pi - 0.001:
# Create full donut using two semicircular arcs with evenodd fill rule
path_commands = [
# Outer circle - first semicircle (top to bottom)
@@ -1440,7 +1436,7 @@ def _make_pie_svg(
inner_start_x,
inner_start_y,
), # Inner arc (reverse)
- ClosePath(), # Close the path
+ ClosePath(),
]
path = Path(
@@ -1455,18 +1451,11 @@ def _make_pie_svg(
if show_labels and not is_na(gt._tbl_data, original_val):
label_text = str(original_val)
- if angle >= 2 * math.pi - 0.001: # Full circle
- # Center the label
- label_x = center_x
- label_y = center_y
- font_size = size * 0.2
- else:
- # Position label in the middle of the donut ring
- label_radius = (radius + inner_radius) / 2
- label_angle = angle / 2
- label_x = center_x + label_radius * math.sin(label_angle)
- label_y = center_y - label_radius * math.cos(label_angle)
- font_size = size * 0.15
+ label_radius = (radius + inner_radius) / 2
+ label_angle = angle / 2
+ label_x = center_x + label_radius * math.sin(label_angle)
+ label_y = center_y - label_radius * math.cos(label_angle)
+ font_size = size * 0.15
text = Text(
text=label_text,
From c3872bd63d3d2a10a2af3222ae5a62910ed4008a Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 13:01:21 -0400
Subject: [PATCH 05/10] gitignore files
---
.gitignore | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.gitignore b/.gitignore
index d347d5e6..d3944920 100644
--- a/.gitignore
+++ b/.gitignore
@@ -124,3 +124,5 @@ sandbox.*
possible_feats.*
compare_themes.py
compare_tables.html
+
+README_FILES/
From 0df8459af404e1b71d3108df207580b847e0b522 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 13:03:48 -0400
Subject: [PATCH 06/10] rename to donut
---
docs/_quarto.yml | 2 +-
gt_extras/__init__.py | 4 ++--
gt_extras/plotting.py | 30 +++++++++++++++---------------
3 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index 26e46114..00f68dfa 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -51,9 +51,9 @@ quartodoc:
- gt_plt_bar_stack
- gt_plt_bullet
- gt_plt_conf_int
+ - gt_plt_donut
- gt_plt_dot
- gt_plt_dumbbell
- - gt_plt_pie
- gt_plt_summary
- gt_plt_winloss
diff --git a/gt_extras/__init__.py b/gt_extras/__init__.py
index 7f98bb15..5671f6e4 100644
--- a/gt_extras/__init__.py
+++ b/gt_extras/__init__.py
@@ -16,9 +16,9 @@
gt_plt_bar_stack,
gt_plt_bullet,
gt_plt_conf_int,
+ gt_plt_donut,
gt_plt_dot,
gt_plt_dumbbell,
- gt_plt_pie,
gt_plt_winloss,
)
from .styling import gt_add_divider
@@ -57,7 +57,7 @@
"gt_plt_dot",
"gt_plt_conf_int",
"gt_plt_dumbbell",
- "gt_plt_pie",
+ "gt_plt_donut",
"gt_plt_winloss",
"gt_plt_bar_stack",
"with_hyperlink",
diff --git a/gt_extras/plotting.py b/gt_extras/plotting.py
index 32996c73..9eab86f6 100644
--- a/gt_extras/plotting.py
+++ b/gt_extras/plotting.py
@@ -40,9 +40,9 @@
"gt_plt_bar_stack",
"gt_plt_bullet",
"gt_plt_conf_int",
+ "gt_plt_donut",
"gt_plt_dot",
"gt_plt_dumbbell",
- "gt_plt_pie",
"gt_plt_winloss",
]
@@ -1223,7 +1223,7 @@ def _make_dumbbell_svg(
return res
-def gt_plt_pie(
+def gt_plt_donut(
gt: GT,
columns: SelectExpr = None,
fill: str = "purple",
@@ -1236,10 +1236,10 @@ def gt_plt_pie(
keep_columns: bool = False,
) -> GT:
"""
- Create pie charts in `GT` cells.
+ Create donut charts in `GT` cells.
- The `gt_plt_pie()` function takes an existing `GT` object and adds pie charts to
- specified columns. Each cell value is represented as a portion of a full pie chart,
+ The `gt_plt_donut()` function takes an existing `GT` object and adds donut charts to
+ specified columns. Each cell value is represented as a portion of a full donut chart,
with the chart size proportional to the cell's numeric value relative to the column's
maximum value. The maximum value in the column will display as a full circle.
@@ -1250,26 +1250,26 @@ def gt_plt_pie(
columns
The columns to target. Can be a single column or a list of columns (by name or index).
- If `None`, the pie chart is applied to all numeric columns.
+ If `None`, the donut chart is applied to all numeric columns.
fill
- The fill color for the pie chart segments.
+ The fill color for the donut chart segments.
size
- The diameter of the pie chart in pixels.
+ The diameter of the donut chart in pixels.
stroke_color
- The color of the border around the pie chart. The default is white, but if
+ The color of the border around the donut chart. The default is white, but if
`None` is passed, no stroke will be drawn.
stroke_width
The width of the border stroke in pixels.
show_labels
- Whether or not to show labels on the pie charts.
+ Whether or not to show labels on the donut charts.
label_color
- The color of text labels on the pie charts (when `show_labels` is `True`).
+ The color of text labels on the donut charts (when `show_labels` is `True`).
domain
The domain of values to use for scaling. This can be a list of floats or integers.
@@ -1284,7 +1284,7 @@ def gt_plt_pie(
Returns
-------
GT
- A `GT` object with pie charts added to the specified columns.
+ A `GT` object with donut charts added to the specified columns.
Examples
--------
@@ -1307,7 +1307,7 @@ def gt_plt_pie(
)
gt.pipe(
- gte.gt_plt_pie,
+ gte.gt_plt_donut,
columns=["hp", "hp_rpm", "trq", "trq_rpm", "mpg_c", "mpg_h"],
size=40,
fill="steelblue"
@@ -1316,7 +1316,7 @@ def gt_plt_pie(
Note
--------
- Each column's pie charts are scaled independently based on that column's min/max values.
+ Each column's donut charts are scaled independently based on that column's min/max values.
A value equal to the column maximum will display as a full circle (360 degrees).
"""
@@ -1500,7 +1500,7 @@ def _make_pie_svg(
)
col_name = col_name + " plot"
- # Apply the scaled value for each row, so the pie is proportional
+ # Apply the scaled value for each row, so the donut is proportional
for i, scaled_val in enumerate(scaled_vals):
res = res.fmt(
lambda original_val, scaled_val=scaled_val: _make_pie_svg(
From 45d6e3d619adf36508a1519cc7171dbfc0d3b72b Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 13:26:18 -0400
Subject: [PATCH 07/10] handling empty case
---
gt_extras/plotting.py | 195 +++++++++++++++++++++++-------------------
1 file changed, 106 insertions(+), 89 deletions(-)
diff --git a/gt_extras/plotting.py b/gt_extras/plotting.py
index 9eab86f6..1a660a8a 100644
--- a/gt_extras/plotting.py
+++ b/gt_extras/plotting.py
@@ -1228,7 +1228,7 @@ def gt_plt_donut(
columns: SelectExpr = None,
fill: str = "purple",
size: float = 30,
- stroke_color: str | None = "white",
+ stroke_color: str | None = None,
stroke_width: float = 1,
show_labels: bool = False,
label_color: str = "black",
@@ -1335,7 +1335,7 @@ def _make_pie_svg(
show_labels: bool,
label_color: str,
) -> str:
- if is_na(gt._tbl_data, original_val) or scaled_val <= 0:
+ if is_na(gt._tbl_data, original_val):
return f'
'
elements = []
@@ -1363,98 +1363,115 @@ def _make_pie_svg(
# Determine if we need a large arc (> 180 degrees)
large_arc = angle > math.pi
- # For full circle (angle >= 2π), we need special handling to avoid degenerate arcs
- if angle >= 2 * math.pi - 0.001:
- # Create full donut using two semicircular arcs with evenodd fill rule
- path_commands = [
- # Outer circle - first semicircle (top to bottom)
- MoveTo(center_x, stroke_width / 2), # Top
- Arc(
- radius - stroke_width / 2,
- radius - stroke_width / 2,
- 0,
- False, # small arc
- True, # clockwise
- center_x,
- center_y + (radius - stroke_width / 2), # Bottom
- ),
- # Outer circle - second semicircle (bottom to top)
- Arc(
- radius - stroke_width / 2,
- radius - stroke_width / 2,
- 0,
- False, # small arc
- True, # clockwise
- center_x,
- stroke_width / 2, # Back to top
- ),
- ClosePath(),
- # Inner circle - first semicircle (top to bottom, counter-clockwise)
- MoveTo(center_x, center_y - inner_radius), # Top of inner circle
- Arc(
- inner_radius,
- inner_radius,
- 0,
- False, # small arc
- False, # counter-clockwise
- center_x,
- center_y + inner_radius, # Bottom of inner circle
- ),
- # Inner circle - second semicircle (bottom to top, counter-clockwise)
- Arc(
- inner_radius,
- inner_radius,
- 0,
- False, # small arc
- False, # counter-clockwise
- center_x,
- center_y - inner_radius, # Back to top
- ),
- ClosePath(),
- ]
- svg_style = "fill-rule:true;"
+ if scaled_val <= 0:
+ stroke_width = max(stroke_width, 1)
+ if stroke_color == "transparent":
+ stroke_color = "black"
+
+ # Draw empty donut with just stroke/outline
+ outer_circle = Circle(
+ cx=center_x,
+ cy=center_y,
+ r=radius - stroke_width / 2,
+ fill="transparent",
+ stroke=stroke_color,
+ stroke_width=stroke_width,
+ stroke_dasharray=3,
+ )
+ elements.append(outer_circle)
+
else:
- # Partial donut using path: outer arc -> line to inner end -> inner arc (reverse) -> close
- path_commands = [
- MoveTo(outer_start_x, outer_start_y), # Start at outer edge
- Arc(
- radius - stroke_width / 2,
- radius - stroke_width / 2,
- 0,
- large_arc,
- True,
- outer_end_x,
- outer_end_y,
- ), # Outer arc
- LineTo(inner_end_x, inner_end_y), # Line to inner edge
- Arc(
- inner_radius,
- inner_radius,
- 0,
- large_arc,
- False, # Reverse direction for inner arc
- inner_start_x,
- inner_start_y,
- ), # Inner arc (reverse)
- ClosePath(),
- ]
-
- path = Path(
- d=path_commands,
- fill=fill,
- stroke=stroke_color,
- stroke_width=stroke_width,
- )
- elements.append(path)
+ # For full circle (angle >= 2π), we need special handling to avoid degenerate arcs
+ if angle >= 2 * math.pi - 0.001:
+ # Create full donut using two semicircular arcs with evenodd fill rule
+ path_commands = [
+ # Outer circle - first semicircle (top to bottom)
+ MoveTo(center_x, stroke_width / 2), # Top
+ Arc(
+ radius - stroke_width / 2,
+ radius - stroke_width / 2,
+ 0,
+ False, # small arc
+ True, # clockwise
+ center_x,
+ center_y + (radius - stroke_width / 2), # Bottom
+ ),
+ # Outer circle - second semicircle (bottom to top)
+ Arc(
+ radius - stroke_width / 2,
+ radius - stroke_width / 2,
+ 0,
+ False, # small arc
+ True, # clockwise
+ center_x,
+ stroke_width / 2, # Back to top
+ ),
+ ClosePath(),
+ # Inner circle - first semicircle (top to bottom, counter-clockwise)
+ MoveTo(center_x, center_y - inner_radius), # Top of inner circle
+ Arc(
+ inner_radius,
+ inner_radius,
+ 0,
+ False, # small arc
+ False, # counter-clockwise
+ center_x,
+ center_y + inner_radius, # Bottom of inner circle
+ ),
+ # Inner circle - second semicircle (bottom to top, counter-clockwise)
+ Arc(
+ inner_radius,
+ inner_radius,
+ 0,
+ False, # small arc
+ False, # counter-clockwise
+ center_x,
+ center_y - inner_radius, # Back to top
+ ),
+ ClosePath(),
+ ]
+ svg_style = "fill-rule:true;"
+ else:
+ # Partial donut using path: outer arc -> line to inner end -> inner arc (reverse) -> close
+ path_commands = [
+ MoveTo(outer_start_x, outer_start_y), # Start at outer edge
+ Arc(
+ radius - stroke_width / 2,
+ radius - stroke_width / 2,
+ 0,
+ large_arc,
+ True,
+ outer_end_x,
+ outer_end_y,
+ ), # Outer arc
+ LineTo(inner_end_x, inner_end_y), # Line to inner edge
+ Arc(
+ inner_radius,
+ inner_radius,
+ 0,
+ large_arc,
+ False, # Reverse direction for inner arc
+ inner_start_x,
+ inner_start_y,
+ ), # Inner arc (reverse)
+ ClosePath(),
+ ]
+
+ path = Path(
+ d=path_commands,
+ fill=fill,
+ stroke=stroke_color,
+ stroke_width=stroke_width,
+ )
+ elements.append(path)
# Add label if requested
if show_labels and not is_na(gt._tbl_data, original_val):
label_text = str(original_val)
- label_radius = (radius + inner_radius) / 2
- label_angle = angle / 2
- label_x = center_x + label_radius * math.sin(label_angle)
- label_y = center_y - label_radius * math.cos(label_angle)
+ # Position label in the center of the donut
+ label_x = center_x
+ label_y = center_y
font_size = size * 0.15
text = Text(
@@ -1465,7 +1482,7 @@ def _make_pie_svg(
font_size=font_size,
text_anchor="middle",
dominant_baseline="central",
- font_weight="bold" if angle >= 2 * math.pi - 0.001 else None,
+ font_weight="bold",
)
elements.append(text)
From 154126e674d06e2b360ae6a12f5dcc4f18b945e1 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 13:48:30 -0400
Subject: [PATCH 08/10] add to examples
---
docs/examples/index.qmd | 5 +++++
docs/examples/with-code.qmd | 29 +++++++++++++++++++++++++++++
2 files changed, 34 insertions(+)
diff --git a/docs/examples/index.qmd b/docs/examples/index.qmd
index feb680eb..2bd0843c 100644
--- a/docs/examples/index.qmd
+++ b/docs/examples/index.qmd
@@ -183,6 +183,11 @@ addEventListener('resize', setTableWidths);
[gt_plt_conf_int](./with-code.html#gt_plt_conf_int){.embed-hover-overlay}
:::
+:::{.masonry-item style="--width: 623;"}
+{{< embed with-code.qmd#gt_plt_donut >}}
+[gt_plt_donut](./with-code.html#gt_plt_donut){.embed-hover-overlay}
+:::
+
:::{.masonry-item style="--width: 304;"}
{{< embed with-code.qmd#gt_plt_dot >}}
[gt_plt_dot](./with-code.html#gt_plt_dot){.embed-hover-overlay}
diff --git a/docs/examples/with-code.qmd b/docs/examples/with-code.qmd
index 5877211b..a3a4f5a1 100644
--- a/docs/examples/with-code.qmd
+++ b/docs/examples/with-code.qmd
@@ -472,6 +472,35 @@ gt.pipe(
```
{{< include ./_show-last.qmd >}}
+### [gt_plt_donut](https://posit-dev.github.io/gt-extras/reference/gt_plt_donut.html){title="Click for reference"}
+```{python}
+# | label: gt_plt_donut
+from great_tables import GT
+from great_tables.data import gtcars
+import gt_extras as gte
+
+gtcars_mini = gtcars.loc[
+ 9:17,
+ ["model", "mfr", "year", "hp", "hp_rpm", "trq", "trq_rpm", "mpg_c", "mpg_h"]
+]
+
+gt = (
+ GT(gtcars_mini, rowname_col="model")
+ .tab_stubhead(label="Car")
+ .cols_align("center")
+ .cols_align("left", columns="mfr")
+)
+
+gt.pipe(
+ gte.gt_plt_donut,
+ columns=["hp", "hp_rpm", "trq", "trq_rpm", "mpg_c", "mpg_h"],
+ size=40,
+ fill="steelblue"
+)
+```
+{{< include ./_show-last.qmd >}}
+
+
### [gt_plt_dot](https://posit-dev.github.io/gt-extras/reference/gt_plt_dot.html){title="Click for reference"}
```{python}
# | label: gt_plt_dot
From b088a5fc50e153c4b3a0cb25cd809bae9e32eae8 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 13:56:21 -0400
Subject: [PATCH 09/10] update docstring
---
gt_extras/plotting.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/gt_extras/plotting.py b/gt_extras/plotting.py
index 1a660a8a..9f26e48b 100644
--- a/gt_extras/plotting.py
+++ b/gt_extras/plotting.py
@@ -1259,8 +1259,8 @@ def gt_plt_donut(
The diameter of the donut chart in pixels.
stroke_color
- The color of the border around the donut chart. The default is white, but if
- `None` is passed, no stroke will be drawn.
+ The color of the border around the donut chart. If `None`, no stroke will be drawn,
+ except for the case of the 0 value.
stroke_width
The width of the border stroke in pixels.
From 9e574e52c3774b8f536f90596f1b4a1541ba98b0 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 13:56:33 -0400
Subject: [PATCH 10/10] add tests!
---
.../tests/__snapshots__/test_plotting.ambr | 39 ++++++
gt_extras/tests/test_plotting.py | 116 ++++++++++++++++++
2 files changed, 155 insertions(+)
diff --git a/gt_extras/tests/__snapshots__/test_plotting.ambr b/gt_extras/tests/__snapshots__/test_plotting.ambr
index b2122c82..37ccf2d3 100644
--- a/gt_extras/tests/__snapshots__/test_plotting.ambr
+++ b/gt_extras/tests/__snapshots__/test_plotting.ambr
@@ -133,6 +133,45 @@
'''
# ---
+# name: test_gt_plt_donut_snap
+ '''
+
+
+
+
apricot
+
one
+
2015-01-15
+
13:35
+
2018-01-01 02:22
+
49.95
+
row_1
+
grp_a
+
+
+
+
banana
+
two
+
2015-02-15
+
14:40
+
2018-02-02 14:33
+
17.95
+
row_2
+
grp_a
+
+
+
+
coconut
+
three
+
2015-03-15
+
15:45
+
2018-03-03 03:44
+
1.39
+
row_3
+
grp_a
+
+
+ '''
+# ---
# name: test_gt_plt_dot_snap
'''
diff --git a/gt_extras/tests/test_plotting.py b/gt_extras/tests/test_plotting.py
index 2797de16..0d8369dc 100644
--- a/gt_extras/tests/test_plotting.py
+++ b/gt_extras/tests/test_plotting.py
@@ -9,6 +9,7 @@
gt_plt_bar_stack,
gt_plt_bullet,
gt_plt_conf_int,
+ gt_plt_donut,
gt_plt_dot,
gt_plt_dumbbell,
gt_plt_winloss,
@@ -1304,3 +1305,118 @@ def test_gt_plt_bullet_scaling():
assert 'x1="58.5px" y1="0" x2="58.5px" y2="30px"' in html
assert 'x1="21.0px" y1="0" x2="21.0px" y2="30px"' in html
assert 'x1="36.0px" y1="0" x2="36.0px" y2="30px"' in html
+
+
+def test_gt_plt_donut_snap(snapshot, mini_gt):
+ res = gt_plt_donut(gt=mini_gt, columns="num")
+
+ assert_rendered_body(snapshot, gt=res)
+
+
+def test_gt_plt_donut_basic(mini_gt):
+ result = gt_plt_donut(gt=mini_gt, columns=["num"])
+ html = result.as_raw_html()
+
+ assert html.count("