293 lines
10 KiB
Python
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,
|
|
}
|