Files
lark-sheets/scripts/lark_chart_size_rules.py

293 lines
10 KiB
Python

#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Pure sizing heuristics shared by Lark chart helper scripts."""
from __future__ import annotations
import math
import unicodedata
from typing import Any
MINIMUM_SIZES = {
"column": (640, 400),
"line": (640, 400),
"area": (640, 400),
"bar": (720, 420),
"combo": (720, 420),
"pie": (720, 440),
}
SUPPORTED_CHART_TYPES = {
"column",
"bar",
"line",
"area",
"pie",
"scatter",
"combo",
"radar",
"bubble",
"waterfall",
"pareto",
}
DEFAULT_MINIMUM_SIZE = (640, 400)
MAX_CHART_WIDTH = 1600
MAX_CHART_HEIGHT = 720
MAX_ASPECT_RATIO = 2.6
COMBO_SERIES_TYPES = {"column", "line", "area", "scatter"}
COMBO_SERIES_Y_AXES = {"left", "right"}
def display_units(value: Any) -> int:
"""Estimate visible text width; CJK/full-width characters count double."""
lines = str(value if value is not None else "").splitlines() or [""]
return max(
sum(2 if unicodedata.east_asian_width(char) in {"W", "F", "A"} else 1 for char in line)
for line in lines
)
def _round_up(value: float, step: int = 40) -> int:
return int(math.ceil(value / step) * step)
def _round_down(value: float, step: int = 40) -> int:
return int(math.floor(value / step) * step)
def _percentile(values: list[int], ratio: float) -> int:
if not values:
return 0
ordered = sorted(values)
return ordered[max(0, math.ceil(len(ordered) * ratio) - 1)]
def _has_clustered_small_slices(values: list[float]) -> bool:
positive = [value for value in values if value > 0]
total = sum(positive)
if not total:
return False
shares = [value / total for value in positive]
return max(shares, default=0) >= 0.75 and sum(share < 0.05 for share in shares) >= 3
def minimum_chart_size(chart_type: str) -> dict[str, int]:
width, height = MINIMUM_SIZES.get(str(chart_type).lower(), DEFAULT_MINIMUM_SIZE)
return {"width": width, "height": height}
def estimate_legend_rows(items: list[str], width: int) -> int:
if not items:
return 0
available = max(240, width - 80)
used = 0
rows = 1
for item in items:
item_width = min(320, 34 + display_units(item) * 7)
if used and used + item_width > available:
rows += 1
used = 0
used += item_width
return rows
def effective_category_labels(
categories: list[Any], *, aggregate_categories: bool = True
) -> list[str]:
labels = [str(value if value is not None else "") for value in categories]
if aggregate_categories:
return list(dict.fromkeys(labels))
return labels
def effective_series_types(
chart_type: str,
series_count: int,
series_types: list[str] | None = None,
) -> list[str]:
chart_type = str(chart_type).lower()
if chart_type != "combo":
if series_types:
raise ValueError("series_types is only valid for combo charts")
return [chart_type] * series_count
if series_types is None:
return ["column", *(["line"] * max(0, series_count - 1))]
normalized = [str(value).strip().lower() for value in series_types]
if len(normalized) != series_count:
raise ValueError("series_types length must match series_names")
invalid = [value for value in normalized if value not in COMBO_SERIES_TYPES]
if invalid:
raise ValueError(f"unsupported combo series type: {invalid[0]}")
return normalized
def effective_series_y_axes(
chart_type: str,
series_count: int,
series_y_axes: list[str] | None = None,
) -> list[str]:
chart_type = str(chart_type).lower()
if chart_type != "combo":
if series_y_axes:
raise ValueError("series_y_axes is only valid for combo charts")
return ["left"] * series_count
if series_y_axes is None:
return ["left", *(["right"] * max(0, series_count - 1))]
normalized = [str(value).strip().lower() for value in series_y_axes]
if len(normalized) != series_count:
raise ValueError("series_y_axes length must match series_names")
invalid = [value for value in normalized if value not in COMBO_SERIES_Y_AXES]
if invalid:
raise ValueError(f"unsupported combo series Y axis: {invalid[0]}")
return normalized
def recommend_chart_size(
*,
chart_type: str,
categories: list[Any],
series_names: list[str],
data_labels: str = "none",
legend_position: str = "bottom",
title: str = "",
values: list[float] | None = None,
aggregate_categories: bool = True,
series_types: list[str] | None = None,
series_y_axes: list[str] | None = None,
) -> dict[str, Any]:
chart_type = str(chart_type).lower()
if chart_type not in SUPPORTED_CHART_TYPES:
raise ValueError(f"unsupported chart type: {chart_type}")
category_text = effective_category_labels(
categories,
aggregate_categories=aggregate_categories,
)
category_count = len(category_text)
series_count = max(1, len(series_names))
normalized_series_types = effective_series_types(
chart_type,
series_count,
series_types,
)
normalized_series_y_axes = effective_series_y_axes(
chart_type,
series_count,
series_y_axes,
)
column_series_count = sum(value == "column" for value in normalized_series_types)
line_like_series_count = series_count - column_series_count
label_units = [display_units(value) for value in category_text]
max_units = max(label_units, default=0)
p75_units = _percentile(label_units, 0.75)
max_lines = max((len(value.splitlines()) for value in category_text), default=1)
labels_enabled = str(data_labels or "").lower() not in {"", "none"}
minimum = minimum_chart_size(chart_type)
width = float(minimum["width"])
height = float(minimum["height"])
reasons: list[str] = []
advice: list[str] = []
if chart_type == "pie":
label_reserve = max(150, min(360, max_units * 7 + 60))
width = max(width, 420 + 2 * label_reserve)
if labels_enabled:
reasons.append("outside_slice_labels")
if values and _has_clustered_small_slices(values):
height += 40
reasons.append("clustered_small_slices")
if category_count > 8:
advice.append("prefer_bar_or_top_n")
size_alone_is_insufficient = category_count > 12
elif chart_type == "bar":
width = max(width, 420 + max_units * 7)
height = max(height, 190 + category_count * 36)
height_limited = _round_up(height) > MAX_CHART_HEIGHT
size_alone_is_insufficient = category_count > 24
if height_limited:
reasons.append("maximum_height_limited")
if size_alone_is_insufficient:
advice.extend(["use_top_n", "split_chart"])
else:
reserve = (
230
if chart_type == "combo" and "right" in normalized_series_y_axes
else 170
)
line_dominant_combo = chart_type == "combo" and column_series_count == 0
base_slot = 44 if chart_type in {"line", "area"} or line_dominant_combo else 52
text_slot = 20 + p75_units * 7 * 0.72
slot = max(base_slot, min(180, text_slot))
if column_series_count <= 1 and category_count >= 10:
# With many categories, Sheet rotates X-axis labels. Reserving each
# label's full horizontal text width makes single-series charts
# disproportionately wide; density checks below still expand when
# data labels would actually collide.
slot = min(slot, 68)
if column_series_count > 1:
slot = max(slot, 44 + 12 * min(column_series_count - 1, 4))
if chart_type == "combo" and line_like_series_count > 1:
slot += min(12, 4 * (line_like_series_count - 1))
if labels_enabled:
slot += min(24, 4 * series_count)
width = max(width, reserve + category_count * slot)
if p75_units > 12:
height += 40
reasons.append("long_category_labels")
if max_lines > 1:
height += min(120, 40 * (max_lines - 1))
reasons.append("multiline_category_labels")
size_alone_is_insufficient = (
category_count > 20
and (p75_units > 12 or series_count > 3 or labels_enabled)
)
if size_alone_is_insufficient:
advice.extend(["prefer_bar_or_top_n", "split_chart"])
width = min(MAX_CHART_WIDTH, _round_up(width))
aspect_width_limit = max(minimum["width"], _round_down(height * MAX_ASPECT_RATIO))
if width > aspect_width_limit:
width = aspect_width_limit
reasons.append("aspect_ratio_limited")
legend_items = category_text if chart_type == "pie" else series_names
legend_rows = 0
if str(legend_position).lower() != "hidden":
legend_rows = estimate_legend_rows(legend_items, width)
if legend_rows > 1:
height += (legend_rows - 1) * 32
reasons.append("multi_row_legend")
if title:
reasons.append("chart_title")
height = min(MAX_CHART_HEIGHT, _round_up(height))
if size_alone_is_insufficient:
if chart_type == "pie":
height = max(height, 520)
elif chart_type != "bar":
width = max(width, 1200)
height = max(height, 520)
return {
"minimum_size": minimum,
"recommended_size": {"width": width, "height": height},
"create_flags": {"width": width, "height": height},
"evidence": {
"chart_type": chart_type,
"series_count": series_count,
"series_types": normalized_series_types,
"series_y_axes": normalized_series_y_axes,
"column_series_count": column_series_count,
"max_category_display_units": max_units,
"p75_category_display_units": p75_units,
"max_category_line_count": max_lines,
"legend_rows": legend_rows,
"data_labels": data_labels,
"aggregate_categories": aggregate_categories,
"recommended_aspect_ratio": round(width / height, 2),
},
"reasons": list(dict.fromkeys(reasons)),
"layout_advice": list(dict.fromkeys(advice)),
"size_alone_is_insufficient": size_alone_is_insufficient,
}