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'' + svg_content = f'' + + if show_labels and not is_na(gt._tbl_data, original_val): + label_text = str(original_val) + svg_content += f'{label_text}' + + svg_end = "" + 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'' - svg_content = f'' + large_arc = True if angle > math.pi else False + + # Create donut 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(), # Close the path + ] + + 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) - svg_content += f'{label_text}' + # 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) - svg_end = "" - return ( - f'
{svg_start}{svg_content}{svg_end}
' - ) + text = Text( + text=label_text, + x=label_x, + y=label_y, + fill=label_color, + font_size=size * 0.15, + text_anchor="middle", + dominant_baseline="central", + ) + elements.append(text) + + svg = SVG(width=size, height=size, elements=elements) + return f'
{svg.as_str()}
' # Get names of columns columns_resolved = resolve_cols_c(data=gt, expr=columns) From 015c4a91063b1450980e77b712c2c21fb715636f Mon Sep 17 00:00:00 2001 From: Jules <54960783+juleswg23@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:05:24 -0400 Subject: [PATCH 03/10] working version, still bloated --- gt_extras/plotting.py | 178 ++++++++++++++++++++++++------------------ 1 file changed, 102 insertions(+), 76 deletions(-) diff --git a/gt_extras/plotting.py b/gt_extras/plotting.py index ac76eae3..6bf15511 100644 --- a/gt_extras/plotting.py +++ b/gt_extras/plotting.py @@ -1338,70 +1338,87 @@ def _make_pie_svg( if is_na(gt._tbl_data, original_val) or scaled_val <= 0: return f'
' + elements = [] + svg_style = "" + 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 * math.pi + # Clamp to maximum of 2π for full circle + angle = min(scaled_val * 2 * math.pi, 2 * math.pi) - elements = [] + # 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) - if scaled_val >= 1.0: - # Full donut ring using two circles - outer_circle = Circle( - cx=center_x, - cy=center_y, - r=radius - stroke_width / 2, - fill=fill, - stroke=stroke_color, - stroke_width=stroke_width, - ) - 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): - 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) + # 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 = angle > math.pi - svg = SVG(width=size, height=size, elements=elements) - return f'
{svg.as_str()}
' + # 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 + # 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 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 = True if angle > math.pi else False - - # Create donut path: outer arc -> line to inner end -> inner arc (reverse) -> close + # 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( @@ -1426,36 +1443,45 @@ def _make_pie_svg( ClosePath(), # Close the path ] - path = Path( - d=path_commands, - fill=fill, - stroke=stroke_color, - stroke_width=stroke_width, - ) - elements.append(path) + 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) - # Add label if requested - 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 - text = Text( - text=label_text, - x=label_x, - y=label_y, - fill=label_color, - font_size=size * 0.15, - text_anchor="middle", - dominant_baseline="central", - ) - elements.append(text) + text = Text( + text=label_text, + x=label_x, + y=label_y, + fill=label_color, + font_size=font_size, + text_anchor="middle", + dominant_baseline="central", + font_weight="bold" if angle >= 2 * math.pi - 0.001 else None, + ) + elements.append(text) - svg = SVG(width=size, height=size, elements=elements) - return f'
{svg.as_str()}
' + svg = SVG(width=size, height=size, elements=elements, style=svg_style) + return f'
{svg.as_str()}
' # 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("33.33" in html + assert ">2.222" in html + assert ">0.1111" in html + assert html.count('" not in html + + +def test_gt_plt_donut_zero_values(): + df = pd.DataFrame({"values": [0, 10, 0]}) + gt_test = GT(df) + result = gt_plt_donut(gt=gt_test, columns="values", show_labels=True) + html = result.as_raw_html() + + assert ">0" in html + assert html.count('stroke-dasharray="3"') == 2 + + +def test_gt_plt_donut_full_circle(): + df = pd.DataFrame({"values": [100, 100]}) + gt_test = GT(df) + result = gt_plt_donut(gt=gt_test, columns="values") + html = result.as_raw_html() + + assert 'style="fill-rule:true;"' in html + + +def test_gt_plt_donut_custom_colors(mini_gt): + result = gt_plt_donut( + gt=mini_gt, + columns=["num"], + fill="red", + stroke_color="blue", + label_color="green", + show_labels=True, + ) + html = result.as_raw_html() + + assert 'fill="red"' in html + assert 'stroke="blue"' in html + assert 'fill="green"' in html + + +def test_gt_plt_donut_no_stroke_color(mini_gt): + result = gt_plt_donut(gt=mini_gt, columns=["num"], stroke_color=None) + html = result.as_raw_html() + assert 'stroke="transparent"' in html + + +def test_gt_plt_donut_keep_columns(mini_gt): + result = gt_plt_donut(gt=mini_gt, columns=["num"], keep_columns=True) + html = result.as_raw_html() + + assert ">num plot" in html + assert ">num" in html + assert html.count("') == 2 + + +def test_gt_plt_donut_custom_size(mini_gt): + result = gt_plt_donut(gt=mini_gt, columns=["num"], size=50) + html = result.as_raw_html() + + assert html.count('width="50" height="50"') == 3 + + +def test_gt_plt_donut_with_domain_expanded(mini_gt): + result = gt_plt_donut(gt=mini_gt, columns=["num"], domain=[0.1111, 33.33]) + html = result.as_raw_html() + + assert isinstance(result, GT) + + assert '