| 1 | """Deterministic normalized SVG fallback for parsed classic charts. |
| 2 | |
| 3 | The renderer is intentionally independent from the native-chart OOXML |
| 4 | emitter. It visualizes only data and semantics that ``chart_to_svg`` has |
| 5 | already accepted, so a rendering failure can never invalidate an otherwise |
| 6 | valid editable-chart payload. |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import html |
| 12 | import math |
| 13 | import re |
| 14 | from dataclasses import dataclass |
| 15 | from datetime import date, timedelta |
| 16 | from decimal import Decimal, ROUND_HALF_UP |
| 17 | from typing import Any |
| 18 | |
| 19 | |
| 20 | _DEFAULT_COLORS = ( |
| 21 | "#4472C4", |
| 22 | "#ED7D31", |
| 23 | "#A5A5A5", |
| 24 | "#FFC000", |
| 25 | "#5B9BD5", |
| 26 | "#70AD47", |
| 27 | "#264478", |
| 28 | "#9E480E", |
| 29 | ) |
| 30 | |
| 31 | |
| 32 | @dataclass(frozen=True) |
| 33 | class SeriesVisualStyle: |
| 34 | """Resolved paint used only by the normalized SVG fallback.""" |
| 35 | |
| 36 | fill: str | None |
| 37 | fill_opacity: float = 1.0 |
| 38 | stroke: str | None = None |
| 39 | stroke_opacity: float = 1.0 |
| 40 | stroke_width: float = 1.5 |
| 41 | line_cap: str = "round" |
| 42 | marker_fill: str | None = None |
| 43 | marker_fill_opacity: float = 1.0 |
| 44 | marker_stroke: str | None = None |
| 45 | marker_stroke_opacity: float = 1.0 |
| 46 | marker_stroke_width: float = 1.0 |
| 47 | marker_size: float = 5.0 |
| 48 | |
| 49 | |
| 50 | @dataclass(frozen=True) |
| 51 | class _Rect: |
| 52 | x: float |
| 53 | y: float |
| 54 | w: float |
| 55 | h: float |
| 56 | |
| 57 | |
| 58 | def render_normalized_chart_svg( |
| 59 | payload: dict[str, Any], |
| 60 | styles: list[SeriesVisualStyle], |
| 61 | ) -> str | None: |
| 62 | """Render a readable normalized fallback for supported classic charts.""" |
| 63 | chart_type = str(payload.get("type") or "") |
| 64 | if chart_type not in { |
| 65 | "area", "bar", "column", "doughnut", "line", "pie", |
| 66 | "scatter", "bubble", |
| 67 | }: |
| 68 | return None |
| 69 | |
| 70 | try: |
| 71 | bounds = _Rect( |
| 72 | float(payload["x"]), |
| 73 | float(payload["y"]), |
| 74 | float(payload["width"]), |
| 75 | float(payload["height"]), |
| 76 | ) |
| 77 | except (KeyError, TypeError, ValueError, OverflowError): |
| 78 | return None |
| 79 | if ( |
| 80 | bounds.w <= 0 |
| 81 | or bounds.h <= 0 |
| 82 | or not all(math.isfinite(value) for value in vars(bounds).values()) |
| 83 | ): |
| 84 | return None |
| 85 | |
| 86 | legend_entries = _legend_entries(payload, styles) |
| 87 | content, legend_rect, title_parts = _outer_layout( |
| 88 | payload, |
| 89 | bounds, |
| 90 | len(legend_entries), |
| 91 | ) |
| 92 | parts = [*title_parts] |
| 93 | if chart_type in {"pie", "doughnut"}: |
| 94 | parts.extend(_render_pie(payload, styles, content, chart_type == "doughnut")) |
| 95 | elif chart_type in {"scatter", "bubble"}: |
| 96 | parts.extend(_render_xy(payload, styles, content, chart_type)) |
| 97 | else: |
| 98 | parts.extend(_render_category(payload, styles, content, chart_type)) |
| 99 | if legend_rect is not None: |
| 100 | parts.extend( |
| 101 | _render_legend( |
| 102 | legend_entries, |
| 103 | legend_rect, |
| 104 | str(payload.get("legend_position") or "b"), |
| 105 | ) |
| 106 | ) |
| 107 | return "\n".join(part for part in parts if part) |
| 108 | |
| 109 | |
| 110 | def _outer_layout( |
| 111 | payload: dict[str, Any], |
| 112 | bounds: _Rect, |
| 113 | legend_count: int, |
| 114 | ) -> tuple[_Rect, _Rect | None, list[str]]: |
| 115 | pad = max(4.0, min(bounds.w, bounds.h) * 0.018) |
| 116 | content = _Rect( |
| 117 | bounds.x + pad, |
| 118 | bounds.y + pad, |
| 119 | max(1.0, bounds.w - 2 * pad), |
| 120 | max(1.0, bounds.h - 2 * pad), |
| 121 | ) |
| 122 | title_parts: list[str] = [] |
| 123 | title = _entry_text(payload.get("title")) |
| 124 | subtitle = _entry_text(payload.get("subtitle")) |
| 125 | if title: |
| 126 | title_size = _font_size( |
| 127 | _entry_value(payload.get("title"), "font_size"), |
| 128 | min(18.0, max(10.0, bounds.h * 0.055)), |
| 129 | bounds, |
| 130 | ) |
| 131 | title_y = content.y + title_size |
| 132 | title_parts.append( |
| 133 | _text( |
| 134 | content.x + content.w / 2, |
| 135 | title_y, |
| 136 | title, |
| 137 | size=title_size, |
| 138 | anchor="middle", |
| 139 | weight="600", |
| 140 | fill=_entry_color(payload.get("title"), "#333333"), |
| 141 | ) |
| 142 | ) |
| 143 | used = title_size + max(4.0, title_size * 0.35) |
| 144 | content = _Rect(content.x, content.y + used, content.w, max(1.0, content.h - used)) |
| 145 | if subtitle: |
| 146 | subtitle_size = _font_size( |
| 147 | _entry_value(payload.get("subtitle"), "font_size"), |
| 148 | min(12.0, max(8.0, bounds.h * 0.035)), |
| 149 | bounds, |
| 150 | ) |
| 151 | title_parts.append( |
| 152 | _text( |
| 153 | content.x + content.w / 2, |
| 154 | content.y + subtitle_size, |
| 155 | subtitle, |
| 156 | size=subtitle_size, |
| 157 | anchor="middle", |
| 158 | fill=_entry_color(payload.get("subtitle"), "#666666"), |
| 159 | ) |
| 160 | ) |
| 161 | used = subtitle_size + max(3.0, subtitle_size * 0.3) |
| 162 | content = _Rect(content.x, content.y + used, content.w, max(1.0, content.h - used)) |
| 163 | |
| 164 | if not payload.get("show_legend") or legend_count <= 0: |
| 165 | return content, None, title_parts |
| 166 | position = str(payload.get("legend_position") or "b").lower() |
| 167 | if position in {"l", "r", "left", "right"}: |
| 168 | legend_w = min(max(86.0, content.w * 0.24), max(1.0, content.w * 0.38)) |
| 169 | if position in {"l", "left"}: |
| 170 | legend = _Rect(content.x, content.y, legend_w, content.h) |
| 171 | content = _Rect( |
| 172 | content.x + legend_w, |
| 173 | content.y, |
| 174 | max(1.0, content.w - legend_w), |
| 175 | content.h, |
| 176 | ) |
| 177 | else: |
| 178 | legend = _Rect(content.x + content.w - legend_w, content.y, legend_w, content.h) |
| 179 | content = _Rect(content.x, content.y, max(1.0, content.w - legend_w), content.h) |
| 180 | return content, legend, title_parts |
| 181 | |
| 182 | columns = max(1, min(4, int(content.w // 130) or 1)) |
| 183 | rows = math.ceil(legend_count / columns) |
| 184 | legend_h = min(content.h * 0.32, max(20.0, rows * 17.0 + 4.0)) |
| 185 | if position in {"t", "top"}: |
| 186 | legend = _Rect(content.x, content.y, content.w, legend_h) |
| 187 | content = _Rect( |
| 188 | content.x, |
| 189 | content.y + legend_h, |
| 190 | content.w, |
| 191 | max(1.0, content.h - legend_h), |
| 192 | ) |
| 193 | else: |
| 194 | legend = _Rect(content.x, content.y + content.h - legend_h, content.w, legend_h) |
| 195 | content = _Rect(content.x, content.y, content.w, max(1.0, content.h - legend_h)) |
| 196 | return content, legend, title_parts |
| 197 | |
| 198 | |
| 199 | def _render_category( |
| 200 | payload: dict[str, Any], |
| 201 | styles: list[SeriesVisualStyle], |
| 202 | content: _Rect, |
| 203 | chart_type: str, |
| 204 | ) -> list[str]: |
| 205 | categories = _category_labels(payload) |
| 206 | series = payload.get("series") or [] |
| 207 | if categories is None or not categories or not series: |
| 208 | return [] |
| 209 | styles = _complete_styles(styles, len(series)) |
| 210 | axis_titles = payload.get("axis_titles") if isinstance(payload.get("axis_titles"), dict) else {} |
| 211 | is_bar = chart_type == "bar" |
| 212 | label_size = max(6.0, min(11.0, content.h * 0.037, content.w * 0.021)) |
| 213 | left = max(34.0, content.w * 0.075) |
| 214 | bottom = max(25.0, content.h * 0.105) |
| 215 | if is_bar: |
| 216 | left = min(content.w * 0.34, max(66.0, content.w * 0.18)) |
| 217 | bottom = max(24.0, content.h * 0.085) |
| 218 | if axis_titles.get("value"): |
| 219 | left += 14.0 if not is_bar else 0.0 |
| 220 | bottom += 14.0 if is_bar else 0.0 |
| 221 | if axis_titles.get("category"): |
| 222 | bottom += 14.0 if not is_bar else 0.0 |
| 223 | left += 14.0 if is_bar else 0.0 |
| 224 | plot = _Rect( |
| 225 | content.x + left, |
| 226 | content.y + 5.0, |
| 227 | max(12.0, content.w - left - 10.0), |
| 228 | max(12.0, content.h - bottom - 8.0), |
| 229 | ) |
| 230 | |
| 231 | grouping = str(payload.get("grouping") or ("clustered" if chart_type in {"bar", "column"} else "standard")) |
| 232 | segments, percent = _category_segments(series, len(categories), grouping) |
| 233 | scale_values = [value for row in segments for pair in row for value in pair] |
| 234 | lo, hi, ticks = _nice_scale(scale_values, include_zero=True, percent=percent) |
| 235 | parts: list[str] = [] |
| 236 | show_value_labels = payload.get("show_value_axis_labels") is not False |
| 237 | |
| 238 | if is_bar: |
| 239 | parts.extend(_horizontal_grid_and_ticks( |
| 240 | plot, |
| 241 | ticks, |
| 242 | lo, |
| 243 | hi, |
| 244 | label_size, |
| 245 | percent, |
| 246 | show_labels=show_value_labels, |
| 247 | )) |
| 248 | parts.extend(_bar_category_labels(plot, categories, label_size)) |
| 249 | else: |
| 250 | parts.extend(_vertical_grid_and_ticks( |
| 251 | plot, |
| 252 | ticks, |
| 253 | lo, |
| 254 | hi, |
| 255 | label_size, |
| 256 | percent, |
| 257 | show_labels=show_value_labels, |
| 258 | )) |
| 259 | parts.extend(_column_category_labels( |
| 260 | plot, |
| 261 | categories, |
| 262 | label_size, |
| 263 | point_aligned=chart_type in {"line", "area"}, |
| 264 | )) |
| 265 | parts.extend(_axis_titles(plot, content, axis_titles, label_size, is_bar=is_bar)) |
| 266 | |
| 267 | if chart_type in {"bar", "column"}: |
| 268 | parts.extend( |
| 269 | _render_bars( |
| 270 | payload, |
| 271 | categories, |
| 272 | series, |
| 273 | styles, |
| 274 | segments, |
| 275 | plot, |
| 276 | lo, |
| 277 | hi, |
| 278 | grouping, |
| 279 | horizontal=is_bar, |
| 280 | label_size=label_size, |
| 281 | percent=percent, |
| 282 | ) |
| 283 | ) |
| 284 | else: |
| 285 | parts.extend( |
| 286 | _render_lines_or_areas( |
| 287 | payload, |
| 288 | categories, |
| 289 | series, |
| 290 | styles, |
| 291 | segments, |
| 292 | plot, |
| 293 | lo, |
| 294 | hi, |
| 295 | chart_type, |
| 296 | label_size, |
| 297 | percent, |
| 298 | ) |
| 299 | ) |
| 300 | return parts |
| 301 | |
| 302 | |
| 303 | def _category_labels(payload: dict[str, Any]) -> list[str] | None: |
| 304 | raw_categories = payload.get("categories") or [] |
| 305 | axes = payload.get("axes") |
| 306 | category_axis = axes.get("category") if isinstance(axes, dict) else None |
| 307 | if not isinstance(category_axis, dict) or category_axis.get("kind") != "date": |
| 308 | return [str(value) for value in raw_categories] |
| 309 | |
| 310 | number_format = str(category_axis.get("number_format") or "").lower() |
| 311 | normalized_format = re.sub(r'\\.|"[^"]*"|\[[^]]*]', "", number_format) |
| 312 | labels: list[str] = [] |
| 313 | for raw_value in raw_categories: |
| 314 | try: |
| 315 | serial = float(raw_value) |
| 316 | except (TypeError, ValueError, OverflowError): |
| 317 | return None |
| 318 | if not math.isfinite(serial): |
| 319 | return None |
| 320 | date_parts = _excel_1900_date_parts(serial) |
| 321 | if date_parts is None: |
| 322 | return None |
| 323 | year, month, day = date_parts |
| 324 | if "yyyy-mm-dd" in normalized_format: |
| 325 | labels.append(f"{year:04d}-{month:02d}-{day:02d}") |
| 326 | elif "mm/dd/yyyy" in normalized_format: |
| 327 | labels.append(f"{month:02d}/{day:02d}/{year:04d}") |
| 328 | elif "m/d/yyyy" in normalized_format: |
| 329 | labels.append(f"{month}/{day}/{year:04d}") |
| 330 | else: |
| 331 | labels.append(f"{year:04d}-{month:02d}-{day:02d}") |
| 332 | return labels |
| 333 | |
| 334 | |
| 335 | def _excel_1900_date_parts(serial: float) -> tuple[int, int, int] | None: |
| 336 | day_number = math.floor(serial) |
| 337 | if day_number < 1: |
| 338 | return None |
| 339 | if day_number == 60: |
| 340 | return 1900, 2, 29 |
| 341 | epoch = date(1899, 12, 31) if day_number < 60 else date(1899, 12, 30) |
| 342 | try: |
| 343 | value = epoch + timedelta(days=day_number) |
| 344 | except (OverflowError, ValueError): |
| 345 | return None |
| 346 | return value.year, value.month, value.day |
| 347 | |
| 348 | |
| 349 | def _category_segments( |
| 350 | series: list[dict[str, Any]], |
| 351 | count: int, |
| 352 | grouping: str, |
| 353 | ) -> tuple[list[list[tuple[float, float]]], bool]: |
| 354 | raw = [ |
| 355 | [float(value) for value in item.get("values", [])[:count]] |
| 356 | for item in series |
| 357 | ] |
| 358 | percent = grouping == "percentStacked" |
| 359 | if percent: |
| 360 | positive = [sum(max(row[idx], 0.0) for row in raw) for idx in range(count)] |
| 361 | negative = [sum(abs(min(row[idx], 0.0)) for row in raw) for idx in range(count)] |
| 362 | for row in raw: |
| 363 | for idx, value in enumerate(row): |
| 364 | denominator = positive[idx] if value >= 0 else negative[idx] |
| 365 | row[idx] = value / denominator if denominator else 0.0 |
| 366 | if grouping not in {"stacked", "percentStacked"}: |
| 367 | return [[(0.0, value) for value in row] for row in raw], percent |
| 368 | |
| 369 | positive_base = [0.0] * count |
| 370 | negative_base = [0.0] * count |
| 371 | result: list[list[tuple[float, float]]] = [] |
| 372 | for row in raw: |
| 373 | segments: list[tuple[float, float]] = [] |
| 374 | for idx, value in enumerate(row): |
| 375 | if value >= 0: |
| 376 | start = positive_base[idx] |
| 377 | positive_base[idx] += value |
| 378 | end = positive_base[idx] |
| 379 | else: |
| 380 | start = negative_base[idx] |
| 381 | negative_base[idx] += value |
| 382 | end = negative_base[idx] |
| 383 | segments.append((start, end)) |
| 384 | result.append(segments) |
| 385 | return result, percent |
| 386 | |
| 387 | |
| 388 | def _render_bars( |
| 389 | payload: dict[str, Any], |
| 390 | categories: list[str], |
| 391 | series: list[dict[str, Any]], |
| 392 | styles: list[SeriesVisualStyle], |
| 393 | segments: list[list[tuple[float, float]]], |
| 394 | plot: _Rect, |
| 395 | lo: float, |
| 396 | hi: float, |
| 397 | grouping: str, |
| 398 | *, |
| 399 | horizontal: bool, |
| 400 | label_size: float, |
| 401 | percent: bool, |
| 402 | ) -> list[str]: |
| 403 | parts: list[str] = [] |
| 404 | stacked = grouping in {"stacked", "percentStacked"} |
| 405 | category_span = (plot.h if horizontal else plot.w) / max(len(categories), 1) |
| 406 | cluster = category_span * 0.72 |
| 407 | bar_span = cluster if stacked else cluster / max(len(series), 1) |
| 408 | labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None |
| 409 | for series_index, (item, style, row) in enumerate(zip(series, styles, segments)): |
| 410 | fill = style.fill or "none" |
| 411 | stroke = style.stroke or "none" |
| 412 | for category_index, (start, end) in enumerate(row): |
| 413 | offset = 0.0 if stacked else series_index * bar_span |
| 414 | display_index = ( |
| 415 | len(categories) - 1 - category_index |
| 416 | if horizontal else category_index |
| 417 | ) |
| 418 | cluster_start = display_index * category_span + (category_span - cluster) / 2 |
| 419 | if horizontal: |
| 420 | x1 = _map(end, lo, hi, plot.x, plot.x + plot.w) |
| 421 | x0 = _map(start, lo, hi, plot.x, plot.x + plot.w) |
| 422 | x = min(x0, x1) |
| 423 | y = plot.y + cluster_start + offset |
| 424 | w = max(abs(x1 - x0), 0.35) |
| 425 | h = max(bar_span * 0.9, 0.5) |
| 426 | else: |
| 427 | y1 = _map(end, lo, hi, plot.y + plot.h, plot.y) |
| 428 | y0 = _map(start, lo, hi, plot.y + plot.h, plot.y) |
| 429 | x = plot.x + cluster_start + offset |
| 430 | y = min(y0, y1) |
| 431 | w = max(bar_span * 0.9, 0.5) |
| 432 | h = max(abs(y1 - y0), 0.35) |
| 433 | parts.append( |
| 434 | f'<rect x="{_fmt(x)}" y="{_fmt(y)}" width="{_fmt(w)}" ' |
| 435 | f'height="{_fmt(h)}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" ' |
| 436 | f'stroke="{stroke}" stroke-opacity="{_fmt(style.stroke_opacity)}" ' |
| 437 | f'stroke-width="{_fmt(max(0.4, style.stroke_width))}"/>' |
| 438 | ) |
| 439 | if labels: |
| 440 | value = float(item["values"][category_index]) |
| 441 | normalized_percent = end - start if percent else None |
| 442 | label = _data_label( |
| 443 | labels, |
| 444 | str(item.get("name") or ""), |
| 445 | categories[category_index], |
| 446 | value, |
| 447 | percent_value=normalized_percent, |
| 448 | ) |
| 449 | if label: |
| 450 | if horizontal: |
| 451 | tx = x1 + (4.0 if end >= start else -4.0) |
| 452 | ty = y + h / 2 + label_size * 0.32 |
| 453 | anchor = "start" if end >= start else "end" |
| 454 | else: |
| 455 | tx = x + w / 2 |
| 456 | ty = y1 - 4.0 if end >= start else y1 + label_size + 3.0 |
| 457 | anchor = "middle" |
| 458 | parts.append(_text(tx, ty, label, size=max(6.0, label_size - 1), anchor=anchor)) |
| 459 | return parts |
| 460 | |
| 461 | |
| 462 | def _render_lines_or_areas( |
| 463 | payload: dict[str, Any], |
| 464 | categories: list[str], |
| 465 | series: list[dict[str, Any]], |
| 466 | styles: list[SeriesVisualStyle], |
| 467 | segments: list[list[tuple[float, float]]], |
| 468 | plot: _Rect, |
| 469 | lo: float, |
| 470 | hi: float, |
| 471 | chart_type: str, |
| 472 | label_size: float, |
| 473 | percent: bool, |
| 474 | ) -> list[str]: |
| 475 | parts: list[str] = [] |
| 476 | count = len(categories) |
| 477 | x_positions = [ |
| 478 | _category_point_x(plot, idx, count) |
| 479 | for idx in range(count) |
| 480 | ] |
| 481 | labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None |
| 482 | show_markers = chart_type == "line" and payload.get("line_style") == "lineMarker" |
| 483 | for series_index, (item, style, row) in enumerate(zip(series, styles, segments)): |
| 484 | fill_color = style.fill or "none" |
| 485 | line_color = style.stroke |
| 486 | top = [ |
| 487 | (x_positions[idx], _map(end, lo, hi, plot.y + plot.h, plot.y)) |
| 488 | for idx, (_start, end) in enumerate(row) |
| 489 | ] |
| 490 | if chart_type == "area": |
| 491 | bottom = [ |
| 492 | (x_positions[idx], _map(start, lo, hi, plot.y + plot.h, plot.y)) |
| 493 | for idx, (start, _end) in enumerate(row) |
| 494 | ] |
| 495 | points = top + list(reversed(bottom)) |
| 496 | parts.append( |
| 497 | f'<polygon points="{_points(points)}" fill="{fill_color}" ' |
| 498 | f'fill-opacity="{_fmt(min(style.fill_opacity, 0.58))}" ' |
| 499 | f'stroke="{line_color or "none"}" stroke-opacity="{_fmt(style.stroke_opacity)}" ' |
| 500 | f'stroke-width="{_fmt(max(0.7, style.stroke_width))}" ' |
| 501 | 'stroke-linejoin="round"/>' |
| 502 | ) |
| 503 | elif line_color is not None: |
| 504 | parts.append( |
| 505 | f'<polyline points="{_points(top)}" fill="none" stroke="{line_color}" ' |
| 506 | f'stroke-opacity="{_fmt(style.stroke_opacity)}" ' |
| 507 | f'stroke-width="{_fmt(max(1.0, style.stroke_width))}" ' |
| 508 | f'stroke-linecap="{style.line_cap}" stroke-linejoin="round"/>' |
| 509 | ) |
| 510 | if show_markers: |
| 511 | for x, y in top: |
| 512 | radius = max(2.0, style.marker_size / 2) |
| 513 | parts.append( |
| 514 | f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(radius)}" ' |
| 515 | f'fill="{style.marker_fill or "none"}" ' |
| 516 | f'fill-opacity="{_fmt(style.marker_fill_opacity)}" ' |
| 517 | f'stroke="{style.marker_stroke or "none"}" ' |
| 518 | f'stroke-opacity="{_fmt(style.marker_stroke_opacity)}" ' |
| 519 | f'stroke-width="{_fmt(max(0.6, style.marker_stroke_width))}"/>' |
| 520 | ) |
| 521 | if labels: |
| 522 | for idx, (x, y) in enumerate(top): |
| 523 | value = float(item["values"][idx]) |
| 524 | start, end = row[idx] |
| 525 | normalized_percent = end - start if percent else None |
| 526 | label = _data_label( |
| 527 | labels, |
| 528 | str(item.get("name") or ""), |
| 529 | categories[idx], |
| 530 | value, |
| 531 | percent_value=normalized_percent, |
| 532 | ) |
| 533 | if label: |
| 534 | parts.append(_text(x, y - 5.0, label, size=max(6.0, label_size - 1), anchor="middle")) |
| 535 | return parts |
| 536 | |
| 537 | |
| 538 | def _render_pie( |
| 539 | payload: dict[str, Any], |
| 540 | styles: list[SeriesVisualStyle], |
| 541 | content: _Rect, |
| 542 | doughnut: bool, |
| 543 | ) -> list[str]: |
| 544 | categories = [str(value) for value in payload.get("categories") or []] |
| 545 | series = payload.get("series") or [] |
| 546 | if not categories or len(series) != 1: |
| 547 | return [] |
| 548 | values = [abs(float(value)) for value in series[0].get("values") or []] |
| 549 | total = sum(values) |
| 550 | styles = _complete_styles(styles, len(categories)) |
| 551 | label_size = max(6.0, min(10.0, content.h * 0.035, content.w * 0.018)) |
| 552 | radius = max(5.0, min(content.w, content.h) * 0.34) |
| 553 | cx = content.x + content.w / 2 |
| 554 | cy = content.y + content.h / 2 |
| 555 | if total <= 0: |
| 556 | parts = [ |
| 557 | f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(radius)}" ' |
| 558 | 'fill="none" stroke="#B8C0CC" stroke-width="1.2" stroke-dasharray="4 3"/>' |
| 559 | ] |
| 560 | if doughnut: |
| 561 | parts.append( |
| 562 | f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(radius * 0.75)}" ' |
| 563 | 'fill="none" stroke="#D5DAE1" stroke-width="1"/>' |
| 564 | ) |
| 565 | parts.append( |
| 566 | _text(cx, cy + label_size * 0.32, "0", size=label_size, anchor="middle") |
| 567 | ) |
| 568 | return parts |
| 569 | parts: list[str] = [] |
| 570 | angle = -math.pi / 2 |
| 571 | for idx, (category, value, style) in enumerate(zip(categories, values, styles)): |
| 572 | sweep = math.tau * value / total |
| 573 | end = angle + sweep |
| 574 | # Pie styles are completed before rendering, so ``None`` here means |
| 575 | # the source explicitly used ``noFill`` rather than an absent style. |
| 576 | fill = style.fill or "none" |
| 577 | stroke = style.stroke or "none" |
| 578 | if sweep >= math.tau - 1e-9: |
| 579 | if doughnut: |
| 580 | inner = radius * 0.75 |
| 581 | path = _full_ring_path(cx, cy, radius, inner) |
| 582 | parts.append( |
| 583 | f'<path d="{path}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" ' |
| 584 | f'stroke="{stroke}" stroke-width="{_fmt(max(0.6, style.stroke_width))}" ' |
| 585 | 'fill-rule="evenodd"/>' |
| 586 | ) |
| 587 | else: |
| 588 | parts.append( |
| 589 | f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(radius)}" ' |
| 590 | f'fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" ' |
| 591 | f'stroke="{stroke}" stroke-width="{_fmt(max(0.6, style.stroke_width))}"/>' |
| 592 | ) |
| 593 | else: |
| 594 | path = _sector_path(cx, cy, radius, angle, end, radius * 0.75 if doughnut else 0.0) |
| 595 | parts.append( |
| 596 | f'<path d="{path}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" ' |
| 597 | f'stroke="{stroke}" stroke-opacity="{_fmt(style.stroke_opacity)}" ' |
| 598 | f'stroke-width="{_fmt(max(0.6, style.stroke_width))}" fill-rule="evenodd"/>' |
| 599 | ) |
| 600 | mid = angle + sweep / 2 |
| 601 | label_radius = radius * (0.87 if doughnut else 0.68) |
| 602 | lx = cx + math.cos(mid) * label_radius |
| 603 | ly = cy + math.sin(mid) * label_radius + label_size * 0.32 |
| 604 | percent = value / total |
| 605 | label = f"{category} {_format_percent(percent)}" |
| 606 | parts.append( |
| 607 | _text( |
| 608 | lx, |
| 609 | ly, |
| 610 | label, |
| 611 | size=label_size, |
| 612 | anchor="middle", |
| 613 | fill="#444444" if fill == "none" else _contrast_color(fill), |
| 614 | ) |
| 615 | ) |
| 616 | angle = end |
| 617 | return parts |
| 618 | |
| 619 | |
| 620 | def _render_xy( |
| 621 | payload: dict[str, Any], |
| 622 | styles: list[SeriesVisualStyle], |
| 623 | content: _Rect, |
| 624 | chart_type: str, |
| 625 | ) -> list[str]: |
| 626 | series = payload.get("series") or [] |
| 627 | if not series: |
| 628 | return [] |
| 629 | styles = _complete_styles(styles, len(series)) |
| 630 | axis_titles = payload.get("axis_titles") if isinstance(payload.get("axis_titles"), dict) else {} |
| 631 | label_size = max(6.0, min(11.0, content.h * 0.037, content.w * 0.021)) |
| 632 | left = max(40.0, content.w * 0.09) + (14.0 if axis_titles.get("y") else 0.0) |
| 633 | bottom = max(26.0, content.h * 0.1) + (14.0 if axis_titles.get("x") else 0.0) |
| 634 | plot = _Rect( |
| 635 | content.x + left, |
| 636 | content.y + 6.0, |
| 637 | max(12.0, content.w - left - 10.0), |
| 638 | max(12.0, content.h - bottom - 8.0), |
| 639 | ) |
| 640 | x_values = [float(value) for item in series for value in item.get("x") or []] |
| 641 | y_values = [float(value) for item in series for value in item.get("y") or []] |
| 642 | x_lo, x_hi, x_ticks = _nice_scale(x_values, include_zero=False) |
| 643 | y_lo, y_hi, y_ticks = _nice_scale(y_values, include_zero=False) |
| 644 | axes = payload.get("axes") |
| 645 | x_axis = axes.get("x") if isinstance(axes, dict) else None |
| 646 | y_axis = axes.get("y") if isinstance(axes, dict) else None |
| 647 | x_major_gridlines = ( |
| 648 | bool(x_axis.get("major_gridlines", False)) |
| 649 | if isinstance(x_axis, dict) |
| 650 | else False |
| 651 | ) |
| 652 | y_major_gridlines = ( |
| 653 | bool(y_axis.get("major_gridlines", True)) |
| 654 | if isinstance(y_axis, dict) |
| 655 | else True |
| 656 | ) |
| 657 | parts = _xy_grid_and_ticks( |
| 658 | plot, |
| 659 | x_ticks, |
| 660 | y_ticks, |
| 661 | x_lo, |
| 662 | x_hi, |
| 663 | y_lo, |
| 664 | y_hi, |
| 665 | label_size, |
| 666 | show_x_gridlines=x_major_gridlines, |
| 667 | show_y_gridlines=y_major_gridlines, |
| 668 | ) |
| 669 | parts.extend(_xy_axis_titles(plot, content, axis_titles, label_size)) |
| 670 | nonnegative_bubble_sizes = [ |
| 671 | float(value) |
| 672 | for item in series |
| 673 | for value in item.get("sizes") or [] |
| 674 | if float(value) >= 0 |
| 675 | ] |
| 676 | bubble_max = max(max(nonnegative_bubble_sizes or [0.0]), 1e-12) |
| 677 | scatter_style = str(payload.get("scatter_style") or "marker") |
| 678 | has_line = chart_type == "scatter" and scatter_style in {"line", "lineMarker", "smooth", "smoothMarker"} |
| 679 | has_marker = chart_type == "bubble" or scatter_style in {"marker", "lineMarker", "smoothMarker"} |
| 680 | for idx, (item, style) in enumerate(zip(series, styles)): |
| 681 | color = style.stroke or style.fill or style.marker_fill or _DEFAULT_COLORS[idx % len(_DEFAULT_COLORS)] |
| 682 | points = [ |
| 683 | ( |
| 684 | _map(float(x), x_lo, x_hi, plot.x, plot.x + plot.w), |
| 685 | _map(float(y), y_lo, y_hi, plot.y + plot.h, plot.y), |
| 686 | ) |
| 687 | for x, y in zip(item.get("x") or [], item.get("y") or []) |
| 688 | ] |
| 689 | if has_line and style.stroke is not None and len(points) >= 2: |
| 690 | parts.append( |
| 691 | f'<polyline points="{_points(points)}" fill="none" stroke="{color}" ' |
| 692 | f'stroke-opacity="{_fmt(style.stroke_opacity)}" ' |
| 693 | f'stroke-width="{_fmt(max(1.0, style.stroke_width))}" ' |
| 694 | f'stroke-linecap="{style.line_cap}" stroke-linejoin="round"/>' |
| 695 | ) |
| 696 | if has_marker: |
| 697 | sizes = item.get("sizes") or [style.marker_size * style.marker_size] * len(points) |
| 698 | for point_index, (x, y) in enumerate(points): |
| 699 | if chart_type == "bubble": |
| 700 | bubble_size = float(sizes[point_index]) |
| 701 | if bubble_size < 0: |
| 702 | continue |
| 703 | radius = 3.0 + 13.0 * math.sqrt(bubble_size / bubble_max) |
| 704 | else: |
| 705 | radius = max(2.0, style.marker_size / 2) |
| 706 | fill = ( |
| 707 | style.fill or "none" |
| 708 | if chart_type == "bubble" |
| 709 | else style.marker_fill or "none" |
| 710 | ) |
| 711 | stroke = ( |
| 712 | style.stroke or "none" |
| 713 | if chart_type == "bubble" |
| 714 | else style.marker_stroke or "none" |
| 715 | ) |
| 716 | parts.append( |
| 717 | f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(radius)}" ' |
| 718 | f'fill="{fill}" fill-opacity="{_fmt(style.marker_fill_opacity if chart_type == "scatter" else style.fill_opacity)}" ' |
| 719 | f'stroke="{stroke}" stroke-opacity="{_fmt(style.marker_stroke_opacity)}" ' |
| 720 | f'stroke-width="{_fmt(max(0.6, style.marker_stroke_width))}"/>' |
| 721 | ) |
| 722 | return parts |
| 723 | |
| 724 | |
| 725 | def _vertical_grid_and_ticks( |
| 726 | plot: _Rect, |
| 727 | ticks: list[float], |
| 728 | lo: float, |
| 729 | hi: float, |
| 730 | size: float, |
| 731 | percent: bool, |
| 732 | *, |
| 733 | show_labels: bool, |
| 734 | show_gridlines: bool = True, |
| 735 | ) -> list[str]: |
| 736 | parts: list[str] = [] |
| 737 | for tick in ticks: |
| 738 | y = _map(tick, lo, hi, plot.y + plot.h, plot.y) |
| 739 | if show_gridlines: |
| 740 | parts.append( |
| 741 | f'<line x1="{_fmt(plot.x)}" y1="{_fmt(y)}" x2="{_fmt(plot.x + plot.w)}" ' |
| 742 | f'y2="{_fmt(y)}" stroke="#D9D9D9" stroke-width="0.7"/>' |
| 743 | ) |
| 744 | if show_labels: |
| 745 | label = _format_percent(tick) if percent else _format_number(tick) |
| 746 | parts.append(_text(plot.x - 5.0, y + size * 0.32, label, size=size, anchor="end", fill="#666666")) |
| 747 | parts.append( |
| 748 | f'<line x1="{_fmt(plot.x)}" y1="{_fmt(plot.y)}" x2="{_fmt(plot.x)}" ' |
| 749 | f'y2="{_fmt(plot.y + plot.h)}" stroke="#808080" stroke-width="1"/>' |
| 750 | ) |
| 751 | return parts |
| 752 | |
| 753 | |
| 754 | def _horizontal_grid_and_ticks( |
| 755 | plot: _Rect, |
| 756 | ticks: list[float], |
| 757 | lo: float, |
| 758 | hi: float, |
| 759 | size: float, |
| 760 | percent: bool, |
| 761 | *, |
| 762 | show_labels: bool, |
| 763 | ) -> list[str]: |
| 764 | parts: list[str] = [] |
| 765 | for tick in ticks: |
| 766 | x = _map(tick, lo, hi, plot.x, plot.x + plot.w) |
| 767 | parts.append( |
| 768 | f'<line x1="{_fmt(x)}" y1="{_fmt(plot.y)}" x2="{_fmt(x)}" ' |
| 769 | f'y2="{_fmt(plot.y + plot.h)}" stroke="#D9D9D9" stroke-width="0.7"/>' |
| 770 | ) |
| 771 | if show_labels: |
| 772 | label = _format_percent(tick) if percent else _format_number(tick) |
| 773 | parts.append(_text(x, plot.y + plot.h + size + 4.0, label, size=size, anchor="middle", fill="#666666")) |
| 774 | parts.append( |
| 775 | f'<line x1="{_fmt(plot.x)}" y1="{_fmt(plot.y + plot.h)}" ' |
| 776 | f'x2="{_fmt(plot.x + plot.w)}" y2="{_fmt(plot.y + plot.h)}" ' |
| 777 | 'stroke="#808080" stroke-width="1"/>' |
| 778 | ) |
| 779 | return parts |
| 780 | |
| 781 | |
| 782 | def _column_category_labels( |
| 783 | plot: _Rect, |
| 784 | categories: list[str], |
| 785 | size: float, |
| 786 | *, |
| 787 | point_aligned: bool, |
| 788 | ) -> list[str]: |
| 789 | parts: list[str] = [] |
| 790 | span = plot.w / max(len(categories), 1) |
| 791 | rotate = len(categories) > 8 or any(len(value) > 10 for value in categories) |
| 792 | for idx, category in enumerate(categories): |
| 793 | if point_aligned: |
| 794 | x = _category_point_x(plot, idx, len(categories)) |
| 795 | else: |
| 796 | x = plot.x + span * (idx + 0.5) |
| 797 | y = plot.y + plot.h + size + 5.0 |
| 798 | transform = f' transform="rotate(-35 {_fmt(x)} {_fmt(y)})"' if rotate else "" |
| 799 | anchor = "end" if rotate else "middle" |
| 800 | parts.append( |
| 801 | f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="{anchor}" ' |
| 802 | f'font-family="Arial" font-size="{_fmt(size)}" fill="#555555"{transform}>' |
| 803 | f'{html.escape(category)}</text>' |
| 804 | ) |
| 805 | return parts |
| 806 | |
| 807 | |
| 808 | def _category_point_x(plot: _Rect, index: int, count: int) -> float: |
| 809 | """Align line/area data points and their category labels.""" |
| 810 | if count <= 1: |
| 811 | return plot.x + plot.w / 2 |
| 812 | return plot.x + plot.w * index / (count - 1) |
| 813 | |
| 814 | |
| 815 | def _bar_category_labels(plot: _Rect, categories: list[str], size: float) -> list[str]: |
| 816 | span = plot.h / max(len(categories), 1) |
| 817 | return [ |
| 818 | _text( |
| 819 | plot.x - 6.0, |
| 820 | plot.y + span * (len(categories) - idx - 0.5) + size * 0.32, |
| 821 | category, |
| 822 | size=size, |
| 823 | anchor="end", |
| 824 | fill="#555555", |
| 825 | ) |
| 826 | for idx, category in enumerate(categories) |
| 827 | ] |
| 828 | |
| 829 | |
| 830 | def _xy_grid_and_ticks( |
| 831 | plot: _Rect, |
| 832 | x_ticks: list[float], |
| 833 | y_ticks: list[float], |
| 834 | x_lo: float, |
| 835 | x_hi: float, |
| 836 | y_lo: float, |
| 837 | y_hi: float, |
| 838 | size: float, |
| 839 | *, |
| 840 | show_x_gridlines: bool, |
| 841 | show_y_gridlines: bool, |
| 842 | ) -> list[str]: |
| 843 | parts = _vertical_grid_and_ticks( |
| 844 | plot, |
| 845 | y_ticks, |
| 846 | y_lo, |
| 847 | y_hi, |
| 848 | size, |
| 849 | False, |
| 850 | show_labels=True, |
| 851 | show_gridlines=show_y_gridlines, |
| 852 | ) |
| 853 | for tick in x_ticks: |
| 854 | x = _map(tick, x_lo, x_hi, plot.x, plot.x + plot.w) |
| 855 | if show_x_gridlines: |
| 856 | parts.append( |
| 857 | f'<line x1="{_fmt(x)}" y1="{_fmt(plot.y)}" x2="{_fmt(x)}" ' |
| 858 | f'y2="{_fmt(plot.y + plot.h)}" stroke="#D9D9D9" stroke-width="0.7"/>' |
| 859 | ) |
| 860 | parts.append(_text(x, plot.y + plot.h + size + 4.0, _format_number(tick), size=size, anchor="middle", fill="#666666")) |
| 861 | parts.append( |
| 862 | f'<line x1="{_fmt(plot.x)}" y1="{_fmt(plot.y + plot.h)}" ' |
| 863 | f'x2="{_fmt(plot.x + plot.w)}" y2="{_fmt(plot.y + plot.h)}" ' |
| 864 | 'stroke="#808080" stroke-width="1"/>' |
| 865 | ) |
| 866 | return parts |
| 867 | |
| 868 | |
| 869 | def _axis_titles( |
| 870 | plot: _Rect, |
| 871 | content: _Rect, |
| 872 | titles: dict[str, Any], |
| 873 | size: float, |
| 874 | *, |
| 875 | is_bar: bool, |
| 876 | ) -> list[str]: |
| 877 | category = str(titles.get("category") or "") |
| 878 | value = str(titles.get("value") or "") |
| 879 | x_title = value if is_bar else category |
| 880 | y_title = category if is_bar else value |
| 881 | return _draw_axis_titles(plot, content, x_title, y_title, size) |
| 882 | |
| 883 | |
| 884 | def _xy_axis_titles( |
| 885 | plot: _Rect, |
| 886 | content: _Rect, |
| 887 | titles: dict[str, Any], |
| 888 | size: float, |
| 889 | ) -> list[str]: |
| 890 | return _draw_axis_titles( |
| 891 | plot, |
| 892 | content, |
| 893 | str(titles.get("x") or ""), |
| 894 | str(titles.get("y") or ""), |
| 895 | size, |
| 896 | ) |
| 897 | |
| 898 | |
| 899 | def _draw_axis_titles( |
| 900 | plot: _Rect, |
| 901 | content: _Rect, |
| 902 | x_title: str, |
| 903 | y_title: str, |
| 904 | size: float, |
| 905 | ) -> list[str]: |
| 906 | parts: list[str] = [] |
| 907 | if x_title: |
| 908 | parts.append(_text(plot.x + plot.w / 2, content.y + content.h - 2.0, x_title, size=size, anchor="middle", weight="600")) |
| 909 | if y_title: |
| 910 | x = content.x + size |
| 911 | y = plot.y + plot.h / 2 |
| 912 | parts.append( |
| 913 | f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="middle" ' |
| 914 | f'font-family="Arial" font-size="{_fmt(size)}" font-weight="600" ' |
| 915 | f'fill="#444444" transform="rotate(-90 {_fmt(x)} {_fmt(y)})">' |
| 916 | f'{html.escape(y_title)}</text>' |
| 917 | ) |
| 918 | return parts |
| 919 | |
| 920 | |
| 921 | def _legend_entries( |
| 922 | payload: dict[str, Any], |
| 923 | styles: list[SeriesVisualStyle], |
| 924 | ) -> list[tuple[str, str]]: |
| 925 | chart_type = str(payload.get("type") or "") |
| 926 | if chart_type in {"pie", "doughnut"}: |
| 927 | labels = [str(value) for value in payload.get("categories") or []] |
| 928 | else: |
| 929 | labels = [str(item.get("name") or f"Series {idx + 1}") for idx, item in enumerate(payload.get("series") or [])] |
| 930 | completed = _complete_styles(styles, len(labels)) |
| 931 | return [ |
| 932 | ( |
| 933 | label, |
| 934 | style.fill or style.stroke or style.marker_fill or _DEFAULT_COLORS[idx % len(_DEFAULT_COLORS)], |
| 935 | ) |
| 936 | for idx, (label, style) in enumerate(zip(labels, completed)) |
| 937 | ] |
| 938 | |
| 939 | |
| 940 | def _render_legend( |
| 941 | entries: list[tuple[str, str]], |
| 942 | rect: _Rect, |
| 943 | position: str, |
| 944 | ) -> list[str]: |
| 945 | if not entries: |
| 946 | return [] |
| 947 | size = max(6.0, min(10.0, rect.h * 0.18 if position.lower() in {"l", "r", "left", "right"} else 9.0)) |
| 948 | parts: list[str] = [] |
| 949 | if position.lower() in {"l", "r", "left", "right"}: |
| 950 | row_h = min(18.0, rect.h / max(len(entries), 1)) |
| 951 | for idx, (label, color) in enumerate(entries): |
| 952 | y = rect.y + row_h * (idx + 0.5) |
| 953 | parts.append( |
| 954 | f'<rect x="{_fmt(rect.x + 5)}" y="{_fmt(y - 4)}" width="8" height="8" fill="{color}"/>' |
| 955 | ) |
| 956 | parts.append(_text(rect.x + 18.0, y + size * 0.32, label, size=size, anchor="start", fill="#444444")) |
| 957 | return parts |
| 958 | |
| 959 | columns = max(1, min(4, int(rect.w // 130) or 1)) |
| 960 | rows = math.ceil(len(entries) / columns) |
| 961 | row_h = rect.h / max(rows, 1) |
| 962 | col_w = rect.w / columns |
| 963 | for idx, (label, color) in enumerate(entries): |
| 964 | row = idx // columns |
| 965 | col = idx % columns |
| 966 | x = rect.x + col * col_w + 5.0 |
| 967 | y = rect.y + row_h * (row + 0.5) |
| 968 | parts.append( |
| 969 | f'<rect x="{_fmt(x)}" y="{_fmt(y - 4)}" width="8" height="8" fill="{color}"/>' |
| 970 | ) |
| 971 | parts.append(_text(x + 13.0, y + size * 0.32, label, size=size, anchor="start", fill="#444444")) |
| 972 | return parts |
| 973 | |
| 974 | |
| 975 | def _data_label( |
| 976 | config: dict[str, Any], |
| 977 | series: str, |
| 978 | category: str, |
| 979 | value: float, |
| 980 | *, |
| 981 | percent_value: float | None, |
| 982 | ) -> str: |
| 983 | fields: list[str] = [] |
| 984 | if config.get("show_series"): |
| 985 | fields.append(series) |
| 986 | if config.get("show_category"): |
| 987 | fields.append(category) |
| 988 | if config.get("show_value"): |
| 989 | fields.append(_format_data_label_value( |
| 990 | value, |
| 991 | config.get("number_format"), |
| 992 | )) |
| 993 | if config.get("show_percent"): |
| 994 | if percent_value is None: |
| 995 | raise ValueError( |
| 996 | "normalized percent labels require percent-stacked segments" |
| 997 | ) |
| 998 | number_format = config.get("number_format") |
| 999 | normalized_format = str(number_format or "").strip() |
| 1000 | percent_format = ( |
| 1001 | "0%" |
| 1002 | if not normalized_format or normalized_format.lower() == "general" |
| 1003 | else normalized_format |
| 1004 | ) |
| 1005 | fields.append(_format_data_label_value( |
| 1006 | percent_value, |
| 1007 | percent_format, |
| 1008 | )) |
| 1009 | return " · ".join(field for field in fields if field) |
| 1010 | |
| 1011 | |
| 1012 | def _format_data_label_value(value: float, number_format: Any) -> str: |
| 1013 | """Render the safe numeric subset used by normalized data labels. |
| 1014 | |
| 1015 | Unknown Excel format programs raise so the caller falls back to the |
| 1016 | reconstruction-only route instead of displaying a materially wrong label. |
| 1017 | """ |
| 1018 | code = str(number_format or "").strip() |
| 1019 | if not code or code.lower() == "general": |
| 1020 | return _format_number(value) |
| 1021 | match = re.fullmatch( |
| 1022 | r"(?P<prefix>[$¥¥€£]?)(?P<group>#,##)?0" |
| 1023 | r"(?P<decimals>\.0+)?(?P<percent>%?)", |
| 1024 | code, |
| 1025 | ) |
| 1026 | if match is None: |
| 1027 | raise ValueError(f"unsupported normalized data-label format: {code!r}") |
| 1028 | scaled = value * 100.0 if match.group("percent") else value |
| 1029 | decimals = len((match.group("decimals") or "").lstrip(".")) |
| 1030 | grouping = "," if match.group("group") else "" |
| 1031 | quantum = Decimal(1).scaleb(-decimals) |
| 1032 | rounded = Decimal(str(abs(scaled))).quantize( |
| 1033 | quantum, |
| 1034 | rounding=ROUND_HALF_UP, |
| 1035 | ) |
| 1036 | absolute = format(rounded, f"{grouping}.{decimals}f") |
| 1037 | sign = "-" if scaled < 0 else "" |
| 1038 | return ( |
| 1039 | f"{sign}{match.group('prefix')}{absolute}{match.group('percent')}" |
| 1040 | ) |
| 1041 | |
| 1042 | |
| 1043 | def _nice_scale( |
| 1044 | values: list[float], |
| 1045 | *, |
| 1046 | include_zero: bool, |
| 1047 | percent: bool = False, |
| 1048 | ) -> tuple[float, float, list[float]]: |
| 1049 | finite = [value for value in values if math.isfinite(value)] |
| 1050 | if not finite: |
| 1051 | finite = [0.0, 1.0] |
| 1052 | lo = min(finite) |
| 1053 | hi = max(finite) |
| 1054 | if include_zero: |
| 1055 | lo = min(lo, 0.0) |
| 1056 | hi = max(hi, 0.0) |
| 1057 | if percent: |
| 1058 | lo = min(lo, 0.0) |
| 1059 | hi = max(hi, 1.0) |
| 1060 | if math.isclose(lo, hi): |
| 1061 | delta = max(abs(lo) * 0.1, 1.0) |
| 1062 | lo -= delta |
| 1063 | hi += delta |
| 1064 | raw_step = (hi - lo) / 5.0 |
| 1065 | magnitude = 10 ** math.floor(math.log10(raw_step)) |
| 1066 | normalized = raw_step / magnitude |
| 1067 | nice = 1.0 if normalized <= 1 else 2.0 if normalized <= 2 else 2.5 if normalized <= 2.5 else 5.0 if normalized <= 5 else 10.0 |
| 1068 | step = nice * magnitude |
| 1069 | nice_lo = math.floor(lo / step) * step |
| 1070 | nice_hi = math.ceil(hi / step) * step |
| 1071 | count = max(1, int(round((nice_hi - nice_lo) / step))) |
| 1072 | ticks = [nice_lo + idx * step for idx in range(count + 1)] |
| 1073 | return nice_lo, nice_hi, ticks |
| 1074 | |
| 1075 | |
| 1076 | def _sector_path( |
| 1077 | cx: float, |
| 1078 | cy: float, |
| 1079 | outer: float, |
| 1080 | start: float, |
| 1081 | end: float, |
| 1082 | inner: float, |
| 1083 | ) -> str: |
| 1084 | x1 = cx + math.cos(start) * outer |
| 1085 | y1 = cy + math.sin(start) * outer |
| 1086 | x2 = cx + math.cos(end) * outer |
| 1087 | y2 = cy + math.sin(end) * outer |
| 1088 | large = 1 if end - start > math.pi else 0 |
| 1089 | if inner <= 0: |
| 1090 | return ( |
| 1091 | f"M {_fmt(cx)} {_fmt(cy)} L {_fmt(x1)} {_fmt(y1)} " |
| 1092 | f"A {_fmt(outer)} {_fmt(outer)} 0 {large} 1 {_fmt(x2)} {_fmt(y2)} Z" |
| 1093 | ) |
| 1094 | ix2 = cx + math.cos(end) * inner |
| 1095 | iy2 = cy + math.sin(end) * inner |
| 1096 | ix1 = cx + math.cos(start) * inner |
| 1097 | iy1 = cy + math.sin(start) * inner |
| 1098 | return ( |
| 1099 | f"M {_fmt(x1)} {_fmt(y1)} A {_fmt(outer)} {_fmt(outer)} 0 {large} 1 {_fmt(x2)} {_fmt(y2)} " |
| 1100 | f"L {_fmt(ix2)} {_fmt(iy2)} A {_fmt(inner)} {_fmt(inner)} 0 {large} 0 {_fmt(ix1)} {_fmt(iy1)} Z" |
| 1101 | ) |
| 1102 | |
| 1103 | |
| 1104 | def _full_ring_path(cx: float, cy: float, outer: float, inner: float) -> str: |
| 1105 | return ( |
| 1106 | f"M {_fmt(cx + outer)} {_fmt(cy)} " |
| 1107 | f"A {_fmt(outer)} {_fmt(outer)} 0 1 1 {_fmt(cx - outer)} {_fmt(cy)} " |
| 1108 | f"A {_fmt(outer)} {_fmt(outer)} 0 1 1 {_fmt(cx + outer)} {_fmt(cy)} Z " |
| 1109 | f"M {_fmt(cx + inner)} {_fmt(cy)} " |
| 1110 | f"A {_fmt(inner)} {_fmt(inner)} 0 1 0 {_fmt(cx - inner)} {_fmt(cy)} " |
| 1111 | f"A {_fmt(inner)} {_fmt(inner)} 0 1 0 {_fmt(cx + inner)} {_fmt(cy)} Z" |
| 1112 | ) |
| 1113 | |
| 1114 | |
| 1115 | def _complete_styles( |
| 1116 | styles: list[SeriesVisualStyle], |
| 1117 | count: int, |
| 1118 | ) -> list[SeriesVisualStyle]: |
| 1119 | result = list(styles[:count]) |
| 1120 | while len(result) < count: |
| 1121 | color = _DEFAULT_COLORS[len(result) % len(_DEFAULT_COLORS)] |
| 1122 | result.append( |
| 1123 | SeriesVisualStyle( |
| 1124 | fill=color, |
| 1125 | stroke=color, |
| 1126 | marker_fill=color, |
| 1127 | marker_stroke=color, |
| 1128 | ) |
| 1129 | ) |
| 1130 | return result |
| 1131 | |
| 1132 | |
| 1133 | def _entry_text(value: Any) -> str: |
| 1134 | if isinstance(value, dict): |
| 1135 | return str(value.get("text") or "") |
| 1136 | return str(value or "") |
| 1137 | |
| 1138 | |
| 1139 | def _entry_value(value: Any, key: str) -> Any: |
| 1140 | return value.get(key) if isinstance(value, dict) else None |
| 1141 | |
| 1142 | |
| 1143 | def _entry_color(value: Any, default: str) -> str: |
| 1144 | color = _entry_value(value, "color") |
| 1145 | return str(color) if isinstance(color, str) and color.startswith("#") else default |
| 1146 | |
| 1147 | |
| 1148 | def _font_size(value: Any, default: float, bounds: _Rect) -> float: |
| 1149 | try: |
| 1150 | size = float(value) |
| 1151 | except (TypeError, ValueError, OverflowError): |
| 1152 | size = default |
| 1153 | return max(6.0, min(size, max(8.0, bounds.h * 0.18))) |
| 1154 | |
| 1155 | |
| 1156 | def _text( |
| 1157 | x: float, |
| 1158 | y: float, |
| 1159 | value: str, |
| 1160 | *, |
| 1161 | size: float, |
| 1162 | anchor: str = "start", |
| 1163 | fill: str = "#444444", |
| 1164 | weight: str | None = None, |
| 1165 | ) -> str: |
| 1166 | weight_attr = f' font-weight="{weight}"' if weight else "" |
| 1167 | return ( |
| 1168 | f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="{anchor}" ' |
| 1169 | f'font-family="Arial" font-size="{_fmt(size)}" fill="{fill}"{weight_attr}>' |
| 1170 | f'{html.escape(str(value))}</text>' |
| 1171 | ) |
| 1172 | |
| 1173 | |
| 1174 | def _map(value: float, lo: float, hi: float, out_lo: float, out_hi: float) -> float: |
| 1175 | if math.isclose(lo, hi): |
| 1176 | return (out_lo + out_hi) / 2 |
| 1177 | return out_lo + (value - lo) * (out_hi - out_lo) / (hi - lo) |
| 1178 | |
| 1179 | |
| 1180 | def _points(points: list[tuple[float, float]]) -> str: |
| 1181 | return " ".join(f"{_fmt(x)},{_fmt(y)}" for x, y in points) |
| 1182 | |
| 1183 | |
| 1184 | def _format_number(value: float) -> str: |
| 1185 | if abs(value) >= 1_000_000: |
| 1186 | return f"{value / 1_000_000:.1f}M".replace(".0M", "M") |
| 1187 | if abs(value) >= 1_000: |
| 1188 | return f"{value / 1_000:.1f}K".replace(".0K", "K") |
| 1189 | if math.isclose(value, round(value)): |
| 1190 | return str(int(round(value))) |
| 1191 | return f"{value:.2f}".rstrip("0").rstrip(".") |
| 1192 | |
| 1193 | |
| 1194 | def _format_percent(value: float) -> str: |
| 1195 | percent = value * 100.0 |
| 1196 | return f"{percent:.1f}%".replace(".0%", "%") |
| 1197 | |
| 1198 | |
| 1199 | def _contrast_color(color: str) -> str: |
| 1200 | token = color.lstrip("#") |
| 1201 | try: |
| 1202 | r, g, b = (int(token[idx:idx + 2], 16) for idx in (0, 2, 4)) |
| 1203 | except (ValueError, TypeError): |
| 1204 | return "#FFFFFF" |
| 1205 | luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 |
| 1206 | return "#222222" if luminance > 0.62 else "#FFFFFF" |
| 1207 | |
| 1208 | |
| 1209 | def _fmt(value: float) -> str: |
| 1210 | if not math.isfinite(float(value)): |
| 1211 | return "0" |
| 1212 | rounded = round(float(value), 3) |
| 1213 | if rounded == 0: |
| 1214 | return "0" |
| 1215 | if rounded.is_integer(): |
| 1216 | return str(int(rounded)) |
| 1217 | return f"{rounded:.3f}".rstrip("0").rstrip(".") |
| 1218 |