← Files ClaraARCHIVED FILE
modules/set-overlap-analysis/vendor/modules/charting/mekko_pipeline.py
40.1 KB · Oct 6, 2026 · 06:02 UTC
from __future__ import annotations
import logging
import re
import plotly.graph_objects as go
import polars as pl
from modules.charting.draw_width_and_stacked_plots import (
_center_subplot_titles,
_update_small_multiple_mekko_axes,
mekko_plot,
prepare_small_multiple_mekko_df,
)
from modules.charting.small_multiples_ordering import (
order_small_multiple_facets_by_total as _order_small_multiple_facets_by_total,
)
from modules.charting.setup_fig import setup_fig_for_mekko_charts
from modules.data.common_data_utils import show_only_largest
from modules.data.multidimensional_charts_prep import prepare_data_for_width_plot
from modules.utilities.config import get_naming_params
from modules.utilities.helpers import check_if_periods_in_columns
from modules.utilities.utils import (
ensure_lazyframe,
ensure_polars_df,
get_row_count,
get_schema_and_column_names,
is_valid_lazyframe,
)
__all__ = [
"apply_barmekko_display_layout",
"build_pipeline_barmekko",
"build_pipeline_mekko",
]
_AUTO_MIN_SHARE = 0.01
_AUTO_CUMULATIVE_SHARE = 0.95
_AUTO_MIN_KEEP = 2
_AUTO_MAX_KEEP_X = 8
_AUTO_MAX_KEEP_W = 12
_MIN_TWO_LINE_ROW_SHARE = 0.08
_Y_LABEL_PAD = 0.01
_TOTAL_ARROW_Y = 0.9
_BARMEKKO_VALUE_LABEL_FONT_SIZE = 12
_BARMEKKO_MIN_ROW_SHARE_FOR_LABEL = 0.02
_BARMEKKO_MIN_X_SHARE_FOR_INSIDE_LABEL = 0.04
_LOGGER = logging.getLogger(__name__)
def _axis_limits(naming: dict, count: int) -> dict:
return {
naming["numberOfTop"]: count,
naming["aggregateOtherItems"]: False,
}
def _unique_count(lf: pl.LazyFrame, column: str) -> int:
cols, _ = get_schema_and_column_names(lf)
if column not in cols:
return 0
return int(lf.select(pl.col(column).n_unique().alias("count")).collect().item())
def _normalize_palette_name(palette: str | None, naming: dict) -> str | None:
if not palette:
return None
raw = str(palette).strip()
if not raw:
return None
known_values = {
naming["cirqueColorpalette"],
naming["modernColorpalette"],
naming["blueAndGreenColorpalette"],
naming["khakiAndDenimColorpalette"],
naming["poloColorpalette"],
naming["heatingUpColorpalette"],
naming["tableauColorpalette"],
naming["thinkcellColorpalette"],
naming["IBCSColorpalette"],
naming["bainColorpalette"],
naming["mckinseyColorpalette"],
naming["bcgColorpalette"],
naming["occColorpalette"],
naming["deloitteColorpalette"],
naming["powerbiColorpalette"],
naming["symphonyColorpalette"],
naming["greysColorpalette"],
naming["bluesColorpalette"],
naming["orangesColorpalette"],
naming["purplesColorpalette"],
naming["brownsColorpalette"],
}
if raw in known_values:
return raw
key = re.sub(r"[^a-z0-9]+", "", raw.lower())
mapped = {
"pastel": "pastel",
"bold": "bold",
"muted": "muted",
"cirque": naming["cirqueColorpalette"],
"modern": naming["modernColorpalette"],
"bluegreen": naming["blueAndGreenColorpalette"],
"khakidenim": naming["khakiAndDenimColorpalette"],
"polo": naming["poloColorpalette"],
"heatingup": naming["heatingUpColorpalette"],
"tableau": naming["tableauColorpalette"],
"thinkcell": naming["thinkcellColorpalette"],
"ibcs": naming["IBCSColorpalette"],
"bain": naming["bainColorpalette"],
"mckinsey": naming["mckinseyColorpalette"],
"bcg": naming["bcgColorpalette"],
"occ": naming["occColorpalette"],
"deloitte": naming["deloitteColorpalette"],
"powerbi": naming["powerbiColorpalette"],
"symphony": naming["symphonyColorpalette"],
"greys": naming["greysColorpalette"],
"blues": naming["bluesColorpalette"],
"oranges": naming["orangesColorpalette"],
"purples": naming["purplesColorpalette"],
"browns": naming["brownsColorpalette"],
}
return mapped.get(key)
def _auto_top_count(
lf: pl.LazyFrame,
dimension: str,
metric_column: str,
*,
min_share: float = _AUTO_MIN_SHARE,
cumulative_share: float = _AUTO_CUMULATIVE_SHARE,
min_keep: int = _AUTO_MIN_KEEP,
max_keep: int | None = None,
) -> int:
cols, _ = get_schema_and_column_names(lf)
if dimension not in cols or metric_column not in cols:
return 0
try:
totals = (
lf.group_by(dimension)
.agg(pl.col(metric_column).sum().alias("__total"))
.collect()
)
except (pl.exceptions.PolarsError, TypeError, ValueError):
_LOGGER.exception("Auto-aggregation failed while computing totals.")
return 0
if totals.is_empty():
return 0
try:
total_sum = float(totals.get_column("__total").sum())
except (TypeError, ValueError):
_LOGGER.exception("Auto-aggregation failed while summing totals.")
return 0
if total_sum <= 0:
count = totals.height
if max_keep is not None and max_keep > 0:
count = min(count, max_keep)
return count
totals = totals.sort("__total", descending=True)
shares = (totals.get_column("__total") / total_sum).to_list()
count = 0
cumulative = 0.0
keep_floor = min_keep if totals.height >= min_keep else totals.height
for share in shares:
if count < keep_floor:
cumulative += float(share)
count += 1
continue
if share < min_share or cumulative >= cumulative_share:
break
cumulative += float(share)
count += 1
final_count = max(count, keep_floor)
if max_keep is not None and max_keep > 0:
final_count = min(final_count, max_keep)
return min(final_count, totals.height)
def _apply_auto_axis_aggregation(
lf: pl.LazyFrame,
chart_dict: dict,
naming: dict,
x_dim: str,
y_dim: str,
metric_column: str,
) -> None:
cols, _ = get_schema_and_column_names(lf)
for axis_key, dimension in [("X", x_dim), ("W", y_dim)]:
if dimension not in cols:
continue
if axis_key not in chart_dict or not isinstance(chart_dict[axis_key], dict):
continue
unique_count = _unique_count(lf, dimension)
if unique_count <= 0:
continue
existing_top = chart_dict[axis_key].get(naming["numberOfTop"], unique_count)
if existing_top < unique_count:
continue
max_keep = _AUTO_MAX_KEEP_X if axis_key == "X" else _AUTO_MAX_KEEP_W
number_of_top = _auto_top_count(
lf,
dimension,
metric_column,
max_keep=max_keep,
)
if number_of_top <= 0:
number_of_top = unique_count
number_of_top = min(number_of_top, unique_count)
chart_dict[axis_key][naming["numberOfTop"]] = number_of_top
chart_dict[axis_key][naming["aggregateOtherItems"]] = (
number_of_top < unique_count
)
def _apply_mekko_label_format(
fig: go.Figure,
*,
include_trace_label: bool = True,
percent_from_area: bool = False,
) -> None:
panel_width_totals: dict[tuple[str, str], float] = {}
panel_area_totals: dict[tuple[str, str], float] = {}
for trace in fig.data:
if getattr(trace, "type", None) != "bar":
continue
width_vals = trace.width
panel_key = (
getattr(trace, "xaxis", "x") or "x",
getattr(trace, "yaxis", "y") or "y",
)
x_vals = trace.x
if not isinstance(width_vals, (list, tuple)):
continue
if panel_key not in panel_width_totals:
try:
panel_width_totals[panel_key] = float(
sum(float(v or 0.0) for v in width_vals)
)
except (TypeError, ValueError):
panel_width_totals[panel_key] = 0.0
if not isinstance(x_vals, (list, tuple)) or len(x_vals) != len(width_vals):
continue
area_sum = 0.0
for width_val, x_val in zip(width_vals, x_vals):
try:
area_sum += float(width_val or 0.0) * float(x_val or 0.0)
except (TypeError, ValueError):
continue
panel_area_totals[panel_key] = panel_area_totals.get(panel_key, 0.0) + area_sum
total_map = panel_area_totals if percent_from_area else panel_width_totals
global_total = float(sum(total_map.values())) if total_map else 0.0
# Small-multiple panels have less horizontal room, so be stricter about two-line labels.
min_share = _MIN_TWO_LINE_ROW_SHARE * (2 if len(total_map) > 1 else 1)
for trace in fig.data:
if getattr(trace, "type", None) != "bar":
continue
text_vals = trace.text
x_vals = trace.x
width_vals = trace.width
if not isinstance(text_vals, (list, tuple)) or not isinstance(
x_vals, (list, tuple)
):
continue
if len(text_vals) != len(x_vals):
continue
if (
width_vals is not None
and isinstance(width_vals, (list, tuple))
and len(width_vals) != len(x_vals)
):
width_vals = None
label = trace.name or ""
panel_key = (
getattr(trace, "xaxis", "x") or "x",
getattr(trace, "yaxis", "y") or "y",
)
panel_total = panel_width_totals.get(panel_key, 0.0)
formatted = []
for idx, (raw_text, raw_x) in enumerate(zip(text_vals, x_vals)):
abs_val = "" if raw_text is None else str(raw_text)
pct_val = None
width_share = None
if width_vals is not None and panel_total > 0 and len(width_vals) > idx:
try:
width_share = float(width_vals[idx] or 0.0) / panel_total
except (TypeError, ValueError, ZeroDivisionError):
width_share = None
use_two_lines = True
if width_share is not None and width_share < min_share:
use_two_lines = False
if width_vals is not None and global_total > 0:
try:
width_val = float(width_vals[idx] or 0.0)
share_val = float(raw_x or 0.0)
pct_val = (width_val * share_val / global_total) * 100.0
except (TypeError, ValueError):
pct_val = None
if pct_val is None and raw_x is not None:
try:
pct_val = float(raw_x)
if pct_val <= 1 and global_total == 0:
pct_val *= 100
except (TypeError, ValueError):
pct_val = None
pct_text = ""
if pct_val is not None:
pct_text = f"{pct_val:.1f}".rstrip("0").rstrip(".")
pct_display = f"{pct_text}%" if pct_text else ""
if label and include_trace_label:
if abs_val and pct_display:
if use_two_lines:
formatted.append(f"{label}<br>{abs_val} ({pct_display})")
else:
formatted.append(label)
elif abs_val:
formatted.append(
f"{label}<br>{abs_val}" if use_two_lines else label
)
elif pct_display:
formatted.append(
f"{label}<br>({pct_display})" if use_two_lines else label
)
else:
formatted.append(label)
else:
if abs_val and pct_display:
formatted.append(f"{abs_val} ({pct_display})")
elif abs_val:
formatted.append(abs_val)
elif pct_display:
formatted.append(f"({pct_display})")
else:
formatted.append("")
trace.text = formatted
trace.hovertext = formatted
def _is_numeric_text_label(text: str | None) -> bool:
if text is None:
return False
plain = re.sub(r"<[^>]+>", "", str(text)).strip()
if not plain:
return False
return bool(re.match(r"^-?\d", plain))
def _strip_percent_suffix(text: str | None) -> str:
if text is None:
return ""
plain = re.sub(r"<[^>]+>", "", str(text)).strip()
return re.sub(r"\s*\([^)]+%\)\s*$", "", plain).strip()
def _format_pct(pct_value: float) -> str:
pct_text = f"{pct_value:.1f}".rstrip("0").rstrip(".")
return f"{pct_text}%"
def _build_barmekko_row_geometry(trace: go.Bar) -> list[dict[str, float]]:
x_vals = trace.x
y_vals = trace.y
width_vals = trace.width
if not isinstance(x_vals, (list, tuple)):
return []
if not isinstance(y_vals, (list, tuple)):
return []
if not isinstance(width_vals, (list, tuple)):
return []
if len(x_vals) != len(y_vals) or len(y_vals) != len(width_vals):
return []
if len(x_vals) == 0:
return []
try:
total_width = float(sum(float(v or 0.0) for v in width_vals))
except (TypeError, ValueError):
return []
if total_width <= 0:
return []
x_abs_values: list[float] = []
for value in x_vals:
try:
x_abs_values.append(abs(float(value or 0.0)))
except (TypeError, ValueError):
x_abs_values.append(0.0)
x_max = max(x_abs_values) if x_abs_values else 0.0
row_values: list[tuple[float, float, float]] = []
total_area = 0.0
for x_val, y_val, width_val in zip(x_abs_values, y_vals, width_vals):
try:
width_num = float(width_val or 0.0)
center = float(y_val or 0.0) + width_num / 2.0
except (TypeError, ValueError):
continue
area_value = width_num * x_val
total_area += area_value
row_values.append((center, width_num, x_val))
rows: list[dict[str, float]] = []
for center, width_num, x_val in row_values:
row_share = width_num / total_width if total_width > 0 else 0.0
x_share = (x_val / x_max) if x_max > 0 else 0.0
area_share = (width_num * x_val / total_area) if total_area > 0 else 0.0
rows.append(
{
"center": center,
"row_share": row_share,
"x_share": x_share,
"area_share": area_share,
}
)
return rows
def _enforce_barmekko_value_labels(fig: go.Figure) -> None:
rows_by_axis: dict[str, list[dict[str, float]]] = {}
for trace in fig.data:
if getattr(trace, "type", None) != "bar":
continue
axis_key = getattr(trace, "yaxis", "y") or "y"
row_geometry = _build_barmekko_row_geometry(trace)
if row_geometry and axis_key not in rows_by_axis:
rows_by_axis[axis_key] = row_geometry
text_vals = trace.text
if (
isinstance(text_vals, (list, tuple))
and row_geometry
and len(text_vals) == len(row_geometry)
):
filtered_text: list[str] = []
for raw_text, row in zip(text_vals, row_geometry):
keep = row["row_share"] >= _BARMEKKO_MIN_ROW_SHARE_FOR_LABEL
filtered_text.append(_strip_percent_suffix(raw_text) if keep else "")
trace.text = filtered_text
trace.hovertext = filtered_text
existing_textfont = (
trace.textfont.to_plotly_json()
if getattr(trace, "textfont", None) is not None
else {}
)
trace.textfont = {
**existing_textfont,
"size": _BARMEKKO_VALUE_LABEL_FONT_SIZE,
}
trace.textangle = 0
if fig.layout.annotations:
for ann in fig.layout.annotations:
xref = str(getattr(ann, "xref", "") or "")
yref = str(getattr(ann, "yref", "") or "")
if not yref.startswith("y"):
continue
if xref not in {"x", "x domain"}:
continue
if not _is_numeric_text_label(getattr(ann, "text", None)):
continue
row_geometry = rows_by_axis.get(yref)
if row_geometry:
try:
y_value = float(getattr(ann, "y"))
except (TypeError, ValueError):
y_value = 0.0
nearest = min(
row_geometry, key=lambda row: abs(row["center"] - y_value)
)
keep = nearest["row_share"] >= _BARMEKKO_MIN_ROW_SHARE_FOR_LABEL
if xref == "x":
keep = (
keep
and nearest["x_share"] >= _BARMEKKO_MIN_X_SHARE_FOR_INSIDE_LABEL
)
if not keep:
ann.text = ""
elif xref == "x":
base_value = _strip_percent_suffix(getattr(ann, "text", None))
ann.text = (
f"{base_value} ({_format_pct(nearest['area_share'] * 100.0)})"
)
ann_font = (
ann.font.to_plotly_json()
if getattr(ann, "font", None) is not None
else {}
)
ann.font = {**ann_font, "size": _BARMEKKO_VALUE_LABEL_FONT_SIZE}
existing_uniformtext = (
fig.layout.uniformtext.to_plotly_json()
if getattr(fig.layout, "uniformtext", None) is not None
else {}
)
fig.update_layout(
uniformtext={
**existing_uniformtext,
"mode": "hide",
"minsize": _BARMEKKO_VALUE_LABEL_FONT_SIZE,
}
)
def _normalize_title(text: str | None) -> str:
if not text:
return ""
stripped = re.sub(r"<[^>]+>", "", str(text))
return stripped.strip().lower()
def _promote_yaxis_labels_to_annotations(
fig: go.Figure, x_pad: float = _Y_LABEL_PAD
) -> None:
layout_dict = fig.to_plotly_json().get("layout", {})
annotations = list(fig.layout.annotations) if fig.layout.annotations else []
for axis_key in sorted(k for k in layout_dict if k.startswith("yaxis")):
axis = fig.layout[axis_key]
tickvals = getattr(axis, "tickvals", None)
ticktext = getattr(axis, "ticktext", None)
if not tickvals or not ticktext:
continue
anchor = getattr(axis, "anchor", None) or "x"
xaxis_key = "xaxis" if anchor == "x" else f"xaxis{anchor[1:]}"
xaxis = fig.layout[xaxis_key] if xaxis_key in layout_dict else None
domain = getattr(xaxis, "domain", None) if xaxis else None
x_pos = (domain[0] - x_pad) if domain and len(domain) == 2 else 0.0
if x_pos < -0.005:
x_pos = -0.005
yref = "y" if axis_key == "yaxis" else axis_key.replace("axis", "")
for val, label in zip(tickvals, ticktext):
annotations.append(
dict(
x=x_pos,
y=val,
xref="paper",
yref=yref,
text=str(label),
showarrow=False,
xanchor="right",
align="right",
)
)
axis.update(showticklabels=False)
fig.update_layout(annotations=annotations)
def _add_total_percent_arrow(fig: go.Figure, percent: float = 100.0) -> None:
return
def _sort_mekko_columns_by_total(
df: pl.DataFrame,
color_array: list[str],
x_dimension: str,
naming: dict,
) -> tuple[pl.DataFrame, list[str]]:
columns, _ = get_schema_and_column_names(df)
if not columns or len(columns) <= 2:
return df, color_array
if x_dimension in columns:
value_cols = [c for c in columns if c != x_dimension]
else:
x_dimension = columns[0]
value_cols = columns[1:]
if not value_cols:
return df, color_array
aggregate_prefix = str(naming["aggregateOtherItemsName"])
def _is_other_label(label: str) -> bool:
lower = label.strip().lower()
return lower == "other" or lower == "other (aggregated)"
other_cols = []
for c in value_cols:
if not isinstance(c, str):
continue
if aggregate_prefix and c.startswith(aggregate_prefix):
other_cols.append(c)
continue
if _is_other_label(c):
other_cols.append(c)
regular_cols = [c for c in value_cols if c not in other_cols]
if not regular_cols:
return df, color_array
sums_df = df.select([pl.col(c).sum().alias(c) for c in regular_cols])
sums = sums_df.to_dicts()[0] if sums_df.height else {}
sorted_regular = sorted(regular_cols, key=lambda c: sums.get(c, 0.0), reverse=True)
ordered_cols = sorted_regular + other_cols
if ordered_cols == value_cols:
return df, color_array
color_map = {
col: color_array[idx]
for idx, col in enumerate(value_cols)
if idx < len(color_array)
}
new_colors = [color_map.get(col) for col in ordered_cols if col in color_map]
if len(new_colors) < len(color_array):
new_colors += color_array[len(new_colors) :]
new_df = df.select([x_dimension] + ordered_cols)
return new_df, new_colors
def _rename_aggregate_labels(
df: pl.DataFrame,
x_dimension: str,
naming: dict,
) -> pl.DataFrame:
columns, _ = get_schema_and_column_names(df)
aggregate_prefix = str(naming["aggregateOtherItemsName"])
if not aggregate_prefix:
return df
has_other = False
if x_dimension in columns:
try:
values = (
df.select(pl.col(x_dimension).unique())
.get_column(x_dimension)
.to_list()
)
has_other = any(str(v).strip().lower() == "other" for v in values)
except Exception:
has_other = False
if not has_other:
for col in columns:
if col != x_dimension and isinstance(col, str):
if col.strip().lower() == "other":
has_other = True
break
replacement = "Other (aggregated)" if has_other else "Other"
rename_map = {
col: replacement
for col in columns
if col != x_dimension
and isinstance(col, str)
and col.startswith(aggregate_prefix)
}
if rename_map:
df = df.rename(rename_map)
if x_dimension in columns:
df = df.with_columns(
pl.when(pl.col(x_dimension).cast(pl.Utf8).str.starts_with(aggregate_prefix))
.then(pl.lit(replacement))
.otherwise(pl.col(x_dimension))
.alias(x_dimension)
)
return df
def _build_base_chart_dict(
naming: dict,
x_dim: str,
y_dim: str,
metric: str,
period: str,
x_count: int,
y_count: int,
*,
small_multiples: bool,
facet_dim: str | None,
facet_count: int,
) -> dict:
chart_dict = {
naming["chosenChart"]: naming["marimekkoChart"],
naming["xAxisDimension"]: x_dim,
naming["yAxisDimension"]: y_dim,
naming["singleMetric"]: metric,
naming["xAxisMetric"]: metric,
naming["yAxisMetric"]: metric,
naming["showLegend"]: naming["showLegendOnTop"],
naming["showValuesAs"]: naming["absolute"],
naming["aggregateOtherItemsName"]: "",
naming["plotSmallMultiplesOtherCharts"]: small_multiples,
naming["selectedPeriods"]: [period],
naming["toPlotPeriod"]: period,
"X": _axis_limits(naming, x_count),
"W": _axis_limits(naming, y_count),
}
if small_multiples:
chart_dict[naming["smallMultiplesColumn"]] = facet_dim
chart_dict["Y"] = _axis_limits(naming, facet_count)
return chart_dict
def build_pipeline_mekko(
df: pl.DataFrame,
x_dimension: str,
y_dimension: str,
metric_column: str,
period: str,
*,
small_multiples: bool = False,
facet_dimension: str | None = None,
palette: str | None = None,
small_multiples_count: int | None = None,
) -> tuple[go.Figure, list[str]]:
"""Build a marimekko chart using the legacy charting pipeline without UI hooks."""
naming = get_naming_params()
period_col = naming["periodName"]
value_name = naming["valueName"]
figure_name = naming["figureName"]
row_name = naming["rowName"]
col_name = naming["columnName"]
small_multiples_dimension_key = naming["smallMultiplesDimension"]
lf = ensure_lazyframe(df)
cols, _ = get_schema_and_column_names(lf)
for required in (x_dimension, y_dimension, metric_column):
if required not in cols:
raise ValueError(f"Missing required column for Mekko: {required}")
if small_multiples:
if not facet_dimension:
raise ValueError(
"facet_dimension is required for small-multiple Mekko charts."
)
if facet_dimension not in cols:
raise ValueError(f"Missing facet column for Mekko: {facet_dimension}")
if period_col not in cols:
lf = lf.with_columns(pl.lit(period).alias(period_col))
lf = lf.with_columns(pl.col(period_col).cast(pl.Utf8))
lf, period = check_if_periods_in_columns(lf, period)
lf = lf.filter(pl.col(period_col) == period)
if get_row_count(lf) == 0:
raise ValueError("No data to plot.")
monetary_name = naming["monetaryLocalCurrencyName"]
cols, _ = get_schema_and_column_names(lf)
if monetary_name not in cols:
lf = lf.with_columns(pl.col(metric_column).alias(monetary_name))
cols, _ = get_schema_and_column_names(lf)
x_count = _unique_count(lf, x_dimension)
y_count = _unique_count(lf, y_dimension)
facet_count = _unique_count(lf, facet_dimension) if small_multiples else 0
if x_count == 0 or y_count == 0 or (small_multiples and facet_count == 0):
raise ValueError("No data to plot.")
chart_dict = _build_base_chart_dict(
naming,
x_dimension,
y_dimension,
metric_column,
period,
x_count,
y_count,
small_multiples=small_multiples,
facet_dim=facet_dimension,
facet_count=facet_count,
)
if small_multiples:
chart_dict[naming["showLegend"]] = naming["notMetConditionValue"]
if small_multiples:
target_panels = facet_count
if small_multiples_count is not None:
try:
target_panels = int(small_multiples_count)
except (TypeError, ValueError):
target_panels = facet_count
target_panels = max(1, min(target_panels, facet_count))
if target_panels < facet_count and target_panels > 1:
chart_dict["Y"][naming["numberOfTop"]] = max(target_panels - 1, 1)
chart_dict["Y"][naming["aggregateOtherItems"]] = True
else:
chart_dict["Y"][naming["numberOfTop"]] = target_panels
chart_dict["Y"][naming["aggregateOtherItems"]] = False
palette_key = _normalize_palette_name(palette, naming)
if palette_key:
chart_dict[naming["colorpalette"]] = palette_key
_apply_auto_axis_aggregation(
lf,
chart_dict,
naming,
x_dimension,
y_dimension,
metric_column,
)
param_dict: dict = {}
used_color_dict: dict = {}
warnings: list[str] = []
value_cols = [metric_column]
if not small_multiples:
(
df_filtered,
_metric_to_plot,
color_array,
used_color_dict,
chart_dict,
_period,
_,
) = prepare_data_for_width_plot(
lf, period, value_cols, chart_dict, param_dict, used_color_dict
)
df_filtered = ensure_polars_df(df_filtered)
if get_row_count(df_filtered) == 0:
raise ValueError("No data to plot.")
df_filtered, color_array = _sort_mekko_columns_by_total(
df_filtered, color_array, x_dimension, naming
)
df_filtered = _rename_aggregate_labels(df_filtered, x_dimension, naming)
fig, _df_negative, negative_message, chart_dict = mekko_plot(
df_filtered,
chart_dict,
param_dict,
unit_name=value_name,
colors=color_array,
)
if negative_message:
warnings.append(negative_message)
_apply_mekko_label_format(fig)
_promote_yaxis_labels_to_annotations(fig)
_add_total_percent_arrow(fig, 100.0)
return fig, warnings
_df_dump, second_dimension_items, _agg_other, value_cols = show_only_largest(
lf,
facet_dimension,
chart_dict[naming["xAxisDimension"]],
period_col,
value_cols,
chart_dict,
param_dict,
"Y",
)
_df_dump, global_unique_items, _global_agg_other, value_cols = show_only_largest(
lf,
chart_dict[naming["xAxisDimension"]],
None,
period_col,
value_cols,
chart_dict,
param_dict,
"X",
)
second_dimension_items = [
str(item).strip()
for item in second_dimension_items
if item is not None and str(item).strip()
]
if not second_dimension_items:
raise ValueError("No facet values to plot.")
aggregate_prefix = str(naming["aggregateOtherItemsName"])
aggregate_label = None
for item in second_dimension_items:
if isinstance(item, str) and item.startswith(aggregate_prefix):
aggregate_label = item
break
if aggregate_label:
other_labels = {
str(item).strip().lower()
for item in second_dimension_items
if item is not None and item != aggregate_label
}
replacement = (
"Other (aggregated)"
if "other" in other_labels or "others" in other_labels
else "Other"
)
if replacement != aggregate_label:
lf = lf.with_columns(
pl.when(pl.col(facet_dimension) == aggregate_label)
.then(pl.lit(replacement))
.otherwise(pl.col(facet_dimension))
.alias(facet_dimension)
)
second_dimension_items = [
replacement if item == aggregate_label else item
for item in second_dimension_items
]
second_dimension_items = _order_small_multiple_facets_by_total(
lf,
facet_dimension,
metric_column,
second_dimension_items,
aggregate_prefix,
)
chart_dict[naming["numberOfPlottedSmallMultiples"]] = len(second_dimension_items)
param_dict, number_of_cols, _number_of_rows = setup_fig_for_mekko_charts(
df,
facet_dimension,
second_dimension_items,
chart_dict[naming["xAxisDimension"]],
param_dict,
chart_dict,
)
fig = param_dict[figure_name]
chart_dict[small_multiples_dimension_key] = None
chart_dict[row_name], chart_dict[col_name] = 1, 1
for idx, dimension in enumerate(second_dimension_items):
row = idx // number_of_cols + 1
col = idx % number_of_cols + 1
chart_dict[row_name] = row
chart_dict[col_name] = col
chart_dict[small_multiples_dimension_key] = dimension
(
lf_plot,
_metric_to_plot,
color_array,
used_color_dict,
chart_dict,
_period,
) = prepare_small_multiple_mekko_df(
lf,
dimension,
second_dimension_items,
facet_dimension,
value_cols,
chart_dict,
param_dict,
used_color_dict,
period_col,
global_unique_items,
)
if not is_valid_lazyframe(lf_plot):
continue
columns, _ = get_schema_and_column_names(lf_plot)
cols_to_use = [c for c in columns if c != facet_dimension]
df_panel = ensure_polars_df(lf_plot.select(cols_to_use))
df_panel, color_array = _sort_mekko_columns_by_total(
df_panel, color_array, x_dimension, naming
)
df_panel = _rename_aggregate_labels(df_panel, x_dimension, naming)
fig, _df_negative, negative_message, chart_dict = mekko_plot(
df_panel,
chart_dict,
param_dict,
unit_name=value_name,
colors=color_array,
)
if negative_message:
warnings.append(negative_message)
_update_small_multiple_mekko_axes(
fig, naming["marimekkoChart"], naming["barmekkoChart"], 0.0
)
_center_subplot_titles(fig, second_dimension_items)
_apply_mekko_label_format(fig)
_promote_yaxis_labels_to_annotations(fig)
return fig, warnings
def build_pipeline_barmekko(
df: pl.DataFrame,
x_dimension: str,
sales_column: str,
units_column: str,
period: str,
*,
palette: str | None = None,
) -> tuple[go.Figure, list[str]]:
"""Build a barmekko chart where width=units and height=unit price."""
naming = get_naming_params()
period_col = naming["periodName"]
value_name = naming["valueName"]
chosen_chart_key = naming["chosenChart"]
barmekko_chart = naming["barmekkoChart"]
x_axis_dimension_key = naming["xAxisDimension"]
y_axis_dimension_key = naming["yAxisDimension"]
x_axis_metric_key = naming["xAxisMetric"]
y_axis_metric_key = naming["yAxisMetric"]
monetary_name = naming["monetaryLocalCurrencyName"]
units_name = naming["unitsName"]
price_per_unit_name = naming["pricePerUnitName"]
show_legend_key = naming["showLegend"]
show_values_as_key = naming["showValuesAs"]
aggregate_other_items_name_key = naming["aggregateOtherItemsName"]
small_multiples_key = naming["plotSmallMultiplesOtherCharts"]
small_multiples_column_key = naming["smallMultiplesColumn"]
selected_periods_key = naming["selectedPeriods"]
to_plot_period_key = naming["toPlotPeriod"]
sort_axis_key = naming["sortAxis"]
x_axis_sort = naming["xAxisSort"]
lf = ensure_lazyframe(df)
cols, _ = get_schema_and_column_names(lf)
for required in (x_dimension, sales_column, units_column):
if required not in cols:
raise ValueError(f"Missing required column for barmekko: {required}")
if period_col not in cols:
lf = lf.with_columns(pl.lit(period).alias(period_col))
lf = lf.with_columns(pl.col(period_col).cast(pl.Utf8))
lf, period = check_if_periods_in_columns(lf, period)
lf = lf.filter(pl.col(period_col) == period)
if get_row_count(lf) == 0:
raise ValueError("No data to plot.")
lf = lf.with_columns(
[
pl.col(x_dimension).cast(pl.Utf8).fill_null("N/A").alias(x_dimension),
pl.col(sales_column).cast(pl.Float64).fill_null(0.0).alias(monetary_name),
pl.col(units_column).cast(pl.Float64).fill_null(0.0).alias(units_name),
]
)
x_count = _unique_count(lf, x_dimension)
if x_count == 0:
raise ValueError("No data to plot.")
chart_dict = {
chosen_chart_key: barmekko_chart,
x_axis_dimension_key: x_dimension,
# Keep this as a real dimension to avoid legacy width-plot fallback labels like "Value".
y_axis_dimension_key: x_dimension,
naming["singleMetric"]: monetary_name,
naming["multipliedMetric"]: monetary_name,
x_axis_metric_key: units_name,
y_axis_metric_key: price_per_unit_name,
show_legend_key: naming["showLegendOnTop"],
show_values_as_key: naming["absolute"],
aggregate_other_items_name_key: "",
small_multiples_key: False,
small_multiples_column_key: None,
selected_periods_key: [period],
to_plot_period_key: period,
sort_axis_key: x_axis_sort,
"X": _axis_limits(naming, x_count),
}
palette_key = _normalize_palette_name(palette, naming)
if palette_key:
chart_dict[naming["colorpalette"]] = palette_key
_apply_auto_axis_aggregation(
lf,
chart_dict,
naming,
x_dimension,
x_dimension,
monetary_name,
)
param_dict: dict = {
naming["volumeColFound"]: False,
naming["unitsColFound"]: True,
naming["discountColFound"]: False,
naming["marginColFound"]: False,
naming["monetaryLocalCurrencyColFound"]: True,
}
used_color_dict: dict = {}
warnings: list[str] = []
value_cols = [monetary_name, units_name]
(
df_filtered,
_metric_to_plot,
color_array,
used_color_dict,
chart_dict,
_period,
_,
) = prepare_data_for_width_plot(
lf, period, value_cols, chart_dict, param_dict, used_color_dict
)
df_filtered = ensure_polars_df(df_filtered)
if get_row_count(df_filtered) == 0:
raise ValueError("No data to plot.")
df_filtered = _rename_aggregate_labels(df_filtered, x_dimension, naming)
fig, _df_negative, negative_message, chart_dict = mekko_plot(
df_filtered, chart_dict, param_dict, unit_name=value_name, colors=color_array
)
if negative_message:
warnings.append(negative_message)
_apply_mekko_label_format(fig, include_trace_label=False, percent_from_area=True)
_promote_yaxis_labels_to_annotations(fig)
_enforce_barmekko_value_labels(fig)
return fig, warnings
def apply_barmekko_display_layout(fig: go.Figure) -> None:
"""Apply the shared barmekko display geometry used by app and brief renders."""
base_margin = (
fig.layout.margin.to_plotly_json()
if getattr(fig.layout, "margin", None) is not None
else {}
)
bar_traces = [trace for trace in fig.data if getattr(trace, "type", None) == "bar"]
min_bar_x = float("inf")
max_bar_x = float("-inf")
max_right_label_chars = 0
for trace in bar_traces:
trace.cliponaxis = False
trace_text = getattr(trace, "text", None)
if isinstance(trace_text, (list, tuple)):
for text_val in trace_text:
text_len = len(str(text_val or ""))
if text_len > max_right_label_chars:
max_right_label_chars = text_len
trace_x = getattr(trace, "x", None)
if not isinstance(trace_x, (list, tuple)):
continue
for value in trace_x:
try:
num = float(value)
except (TypeError, ValueError):
continue
if num < min_bar_x:
min_bar_x = num
if num > max_bar_x:
max_bar_x = num
has_bar_extent = min_bar_x != float("inf") and max_bar_x != float("-inf")
bar_span = max(0.001, max_bar_x - min_bar_x) if has_bar_extent else 0.0
right_pad = (
max(bar_span * 0.25, max(abs(max_bar_x), 1.0) * 0.08) if has_bar_extent else 0.0
)
x_range: list[float] | None = None
if has_bar_extent:
x_range = [min(0.0, min_bar_x - bar_span * 0.03), max_bar_x + right_pad]
max_left_label_chars = 0
for ann in list(fig.layout.annotations or []):
ann_text = str(getattr(ann, "text", "") or "")
xref = str(getattr(ann, "xref", "") or "")
yref = str(getattr(ann, "yref", "") or "")
xanchor = str(getattr(ann, "xanchor", "") or "")
is_left_category_label = (
xref == "paper"
and yref.startswith("y")
and xanchor == "right"
and "(" not in ann_text
and "<b>" not in ann_text
)
if is_left_category_label:
max_left_label_chars = max(max_left_label_chars, len(ann_text))
right_margin = max(
int(base_margin.get("r") or 0),
min(320, max(120, max_right_label_chars * 7)),
)
left_margin = max(
int(base_margin.get("l") or 0),
min(340, max(100, max_left_label_chars * 8 + 24)),
)
fig.update_layout(
margin={
"t": max(int(base_margin.get("t") or 0), 120),
"r": right_margin,
"b": int(base_margin.get("b") or 80),
"l": left_margin,
"pad": int(base_margin.get("pad") or 0),
},
xaxis={
**(
fig.layout.xaxis.to_plotly_json()
if getattr(fig.layout, "xaxis", None) is not None
else {}
),
"automargin": True,
**({"range": x_range} if x_range is not None else {}),
},
yaxis={
**(
fig.layout.yaxis.to_plotly_json()
if getattr(fig.layout, "yaxis", None) is not None
else {}
),
"automargin": True,
},
)
SHA-256: d7229676db87f714c77d02df808256e7a835e01c771ac1452c3076722028a62d