feat(battlecard): implement mini-chart engine with 4 chart templates (line trend, horizontal bar, utilization gauge, comparison bar)
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 45 KiB |
@@ -1,211 +1,338 @@
|
||||
"""Mini-chart engine for battle card visualizations.
|
||||
|
||||
Generates compact, themed 5x3 inch charts suitable for embedding
|
||||
in FIA battle cards. Uses the existing project styling utilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Mini-chart engine for battle card embeddings."""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.figure # noqa: E402
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.patches as mpatches
|
||||
from matplotlib.path import Path as MPLPath
|
||||
# Python 3.14 matplotlib patch (required)
|
||||
_orig = MPLPath.__deepcopy__
|
||||
def _safe_deepcopy(self, memo):
|
||||
if id(self) in memo:
|
||||
return memo[id(self)]
|
||||
memo[id(self)] = self
|
||||
return self
|
||||
MPLPath.__deepcopy__ = _safe_deepcopy
|
||||
|
||||
from src.utils.styling import ( # noqa: E402
|
||||
AI_SPEND,
|
||||
BUBBLE_ZONE,
|
||||
REVENUE,
|
||||
GRAY_DARK,
|
||||
WHITE,
|
||||
EXPORT_DPI,
|
||||
from pathlib import Path
|
||||
|
||||
from src.utils.styling import (
|
||||
get_theme,
|
||||
apply_theme,
|
||||
EXPORT_DPI,
|
||||
BUBBLE_ZONE,
|
||||
AI_SPEND,
|
||||
REVENUE,
|
||||
WARNING_ZONE,
|
||||
NORMAL_ZONE,
|
||||
GRAY_DARK,
|
||||
GRAY_MEDIUM,
|
||||
WHITE,
|
||||
)
|
||||
from src.utils.export import save_chart
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sizing constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MINI_FIGURE_SIZE = (5, 3) # width, height in inches
|
||||
MINI_FIGURE_SIZE = (5, 3)
|
||||
MINI_DPI = 300
|
||||
|
||||
MIN_LABEL_FONT_SIZE = 9
|
||||
MIN_ANNOTATION_FONT_SIZE = 11
|
||||
MIN_TITLE_FONT_SIZE = 13
|
||||
|
||||
|
||||
class MiniChartEngine:
|
||||
"""Engine for generating compact, themed mini-charts for battle cards.
|
||||
"""Engine for generating compact, themed mini-charts for battle cards."""
|
||||
|
||||
Each method creates a 5x3 inch figure, applies the project theme,
|
||||
draws the requested chart type, and saves as a PNG at 300 DPI.
|
||||
"""
|
||||
def _init_figure(self, title: str):
|
||||
"""Create a themed figure and axes."""
|
||||
plt.rcParams.update(get_theme())
|
||||
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
|
||||
fig.set_facecolor(WHITE)
|
||||
ax.set_facecolor(WHITE)
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.spines["left"].set_color("#cccccc")
|
||||
ax.spines["bottom"].set_color("#cccccc")
|
||||
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold", pad=8)
|
||||
return fig, ax
|
||||
|
||||
def _save(self, fig, save_path: str) -> str:
|
||||
"""Save figure with tight layout."""
|
||||
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
fig.savefig(save_path, dpi=MINI_DPI, bbox_inches="tight", pad_inches=0.15)
|
||||
plt.close(fig)
|
||||
return save_path
|
||||
|
||||
def generate_line_trend(
|
||||
self, data: list[dict], title: str, save_path: str
|
||||
) -> str:
|
||||
"""Generate a line trend mini-chart.
|
||||
self,
|
||||
years,
|
||||
values,
|
||||
title,
|
||||
save_path,
|
||||
highlight_year=None,
|
||||
highlight_value=None,
|
||||
color=GRAY_DARK,
|
||||
secondary_color=None,
|
||||
):
|
||||
"""Single line chart showing trend over time."""
|
||||
fig, ax = self._init_figure(title)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[dict]
|
||||
Each dict should have 'x' and 'y' keys.
|
||||
title : str
|
||||
Chart title.
|
||||
save_path : str
|
||||
Output file path.
|
||||
ax.plot(years, values, color=color, linewidth=2, marker="o", markersize=5)
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Absolute path to the saved PNG file.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
|
||||
apply_theme(fig, ax)
|
||||
# Highlight year
|
||||
if highlight_year is not None:
|
||||
idx = years.index(highlight_year) if highlight_year in years else len(years) - 1
|
||||
ax.axvline(
|
||||
x=highlight_year,
|
||||
color=BUBBLE_ZONE,
|
||||
linestyle="--",
|
||||
linewidth=1,
|
||||
alpha=0.7,
|
||||
)
|
||||
if highlight_value is not None:
|
||||
ax.annotate(
|
||||
str(highlight_value),
|
||||
xy=(highlight_year, values[idx]),
|
||||
xytext=(5, 10),
|
||||
textcoords="offset points",
|
||||
fontsize=MIN_ANNOTATION_FONT_SIZE,
|
||||
fontweight="bold",
|
||||
color=BUBBLE_ZONE,
|
||||
)
|
||||
|
||||
x_values = [d["x"] for d in data]
|
||||
y_values = [d["y"] for d in data]
|
||||
|
||||
ax.plot(x_values, y_values, color=AI_SPEND, linewidth=1.5)
|
||||
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
|
||||
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.set_xlabel("Time", fontsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.set_ylabel("Value", fontsize=MIN_LABEL_FONT_SIZE)
|
||||
|
||||
self._save(fig, save_path)
|
||||
return save_path
|
||||
ax.grid(True, axis="y", alpha=0.4)
|
||||
plt.tight_layout()
|
||||
return self._save(fig, save_path)
|
||||
|
||||
def generate_horizontal_bar(
|
||||
self, data: list[dict], title: str, save_path: str
|
||||
) -> str:
|
||||
"""Generate a horizontal bar mini-chart.
|
||||
self,
|
||||
categories,
|
||||
values,
|
||||
title,
|
||||
save_path,
|
||||
colors=None,
|
||||
value_labels=None,
|
||||
max_value=None,
|
||||
):
|
||||
"""Horizontal bar chart for comparing categories."""
|
||||
fig, ax = self._init_figure(title)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[dict]
|
||||
Each dict should have 'label' and 'value' keys.
|
||||
title : str
|
||||
Chart title.
|
||||
save_path : str
|
||||
Output file path.
|
||||
if colors is None:
|
||||
colors = [AI_SPEND] * len(categories)
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Absolute path to the saved PNG file.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
|
||||
apply_theme(fig, ax)
|
||||
y_pos = range(len(categories))
|
||||
bars = ax.barh(
|
||||
y_pos, values, color=colors[: len(categories)], height=0.55
|
||||
)
|
||||
|
||||
labels = [d["label"] for d in data]
|
||||
values = [d["value"] for d in data]
|
||||
colors = [
|
||||
AI_SPEND, BUBBLE_ZONE, REVENUE, GRAY_DARK, WHITE
|
||||
] * (len(labels) // 5 + 1)
|
||||
# Value labels on bars
|
||||
if value_labels is not None:
|
||||
for bar, label in zip(bars, value_labels):
|
||||
ax.text(
|
||||
bar.get_width() + max(values) * 0.01,
|
||||
bar.get_y() + bar.get_height() / 2,
|
||||
str(label),
|
||||
va="center",
|
||||
fontsize=MIN_LABEL_FONT_SIZE,
|
||||
color=GRAY_DARK,
|
||||
)
|
||||
|
||||
ax.barh(labels, values, color=colors[:len(labels)], height=0.6)
|
||||
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
|
||||
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.set_yticks(list(y_pos))
|
||||
ax.set_yticklabels(categories, fontsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.tick_params(axis="x", labelsize=MIN_LABEL_FONT_SIZE)
|
||||
|
||||
self._save(fig, save_path)
|
||||
return save_path
|
||||
if max_value is not None:
|
||||
ax.set_xlim(0, max_value * 1.15)
|
||||
|
||||
ax.grid(True, axis="x", alpha=0.3)
|
||||
plt.tight_layout()
|
||||
return self._save(fig, save_path)
|
||||
|
||||
def generate_utilization_bar(
|
||||
self, data: list[dict], title: str, save_path: str
|
||||
) -> str:
|
||||
"""Generate a utilization bar chart (e.g. GPU utilization).
|
||||
self,
|
||||
label,
|
||||
percentage,
|
||||
title,
|
||||
save_path,
|
||||
context_text=None,
|
||||
):
|
||||
"""Single horizontal bar showing utilization rate."""
|
||||
fig, ax = self._init_figure(title)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[dict]
|
||||
Each dict should have 'label', 'value', and 'max_value' keys.
|
||||
title : str
|
||||
Chart title.
|
||||
save_path : str
|
||||
Output file path.
|
||||
# Color coding based on percentage
|
||||
if percentage > 50:
|
||||
bar_color = REVENUE # green
|
||||
elif percentage >= 20:
|
||||
bar_color = WARNING_ZONE # orange
|
||||
else:
|
||||
bar_color = BUBBLE_ZONE # red
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Absolute path to the saved PNG file.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
|
||||
apply_theme(fig, ax)
|
||||
# Background track
|
||||
ax.barh(
|
||||
0,
|
||||
100,
|
||||
height=0.6,
|
||||
color="#ecf0f1",
|
||||
edgecolor="#cccccc",
|
||||
linewidth=0.5,
|
||||
)
|
||||
|
||||
labels = [d["label"] for d in data]
|
||||
values = [d["value"] for d in data]
|
||||
max_values = [d.get("max_value", 100) for d in data]
|
||||
# Filled utilization bar
|
||||
bar = ax.barh(0, percentage, height=0.6, color=bar_color)
|
||||
|
||||
# Background bars for max
|
||||
ax.barh(labels, max_values, color="#ecf0f1", height=0.6)
|
||||
# Foreground bars for actual utilization
|
||||
colors = [
|
||||
BUBBLE_ZONE if v / m < 0.3 else REVENUE
|
||||
for v, m in zip(values, max_values)
|
||||
]
|
||||
ax.barh(labels, values, color=colors, height=0.6)
|
||||
# Large percentage annotation on the bar
|
||||
ax.text(
|
||||
percentage / 2,
|
||||
0,
|
||||
f"{percentage:.0f}%",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=MIN_ANNOTATION_FONT_SIZE + 3,
|
||||
fontweight="bold",
|
||||
color=WHITE if percentage > 10 else GRAY_DARK,
|
||||
)
|
||||
|
||||
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
|
||||
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE)
|
||||
# Label
|
||||
ax.set_yticks([])
|
||||
ax.set_xlim(0, 100)
|
||||
ax.text(
|
||||
0.02,
|
||||
-0.25,
|
||||
str(label),
|
||||
transform=ax.transData,
|
||||
fontsize=MIN_LABEL_FONT_SIZE,
|
||||
color=GRAY_DARK,
|
||||
)
|
||||
|
||||
self._save(fig, save_path)
|
||||
return save_path
|
||||
# Context text below the bar
|
||||
if context_text is not None:
|
||||
ax.text(
|
||||
50,
|
||||
-0.55,
|
||||
context_text,
|
||||
ha="center",
|
||||
fontsize=MIN_LABEL_FONT_SIZE - 1,
|
||||
color=GRAY_MEDIUM,
|
||||
style="italic",
|
||||
)
|
||||
|
||||
ax.set_ylim(-0.9, 0.5)
|
||||
ax.axis("off")
|
||||
plt.tight_layout()
|
||||
return self._save(fig, save_path)
|
||||
|
||||
def generate_comparison_bar(
|
||||
self, data: list[dict], title: str, save_path: str
|
||||
) -> str:
|
||||
"""Generate a grouped comparison bar chart.
|
||||
self,
|
||||
categories,
|
||||
values_left,
|
||||
values_right,
|
||||
title,
|
||||
save_path,
|
||||
label_left=None,
|
||||
label_right=None,
|
||||
colors=None,
|
||||
):
|
||||
"""Side-by-side grouped bar chart for comparisons."""
|
||||
fig, ax = self._init_figure(title)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[dict]
|
||||
Each dict should have 'label' and multiple value keys
|
||||
(e.g. 'value_a', 'value_b').
|
||||
title : str
|
||||
Chart title.
|
||||
save_path : str
|
||||
Output file path.
|
||||
if colors is None:
|
||||
color_left = AI_SPEND
|
||||
color_right = GRAY_MEDIUM
|
||||
elif len(colors) >= 2:
|
||||
color_left = colors[0]
|
||||
color_right = colors[1]
|
||||
else:
|
||||
color_left = colors[0] if len(colors) == 1 else AI_SPEND
|
||||
color_right = GRAY_MEDIUM
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Absolute path to the saved PNG file.
|
||||
"""
|
||||
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
|
||||
apply_theme(fig, ax)
|
||||
|
||||
labels = [d["label"] for d in data]
|
||||
keys = [k for k in data[0].keys() if k != "label"]
|
||||
|
||||
import numpy as np
|
||||
|
||||
x = np.arange(len(labels))
|
||||
x = list(range(len(categories)))
|
||||
width = 0.35
|
||||
|
||||
for i, key in enumerate(keys):
|
||||
values = [d.get(key, 0) for d in data]
|
||||
color = AI_SPEND if i == 0 else REVENUE
|
||||
ax.bar(x + i * width, values, width, label=key, color=color)
|
||||
bars_left = ax.bar(
|
||||
[p - width / 2 for p in x],
|
||||
values_left,
|
||||
width,
|
||||
label=label_left or "Left",
|
||||
color=color_left,
|
||||
)
|
||||
bars_right = ax.bar(
|
||||
[p + width / 2 for p in x],
|
||||
values_right,
|
||||
width,
|
||||
label=label_right or "Right",
|
||||
color=color_right,
|
||||
)
|
||||
|
||||
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
|
||||
ax.set_xticks(x + width / 2)
|
||||
ax.set_xticklabels(labels, fontsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(categories, fontsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.tick_params(axis="y", labelsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.legend(fontsize=MIN_LABEL_FONT_SIZE)
|
||||
ax.legend(
|
||||
loc="upper center",
|
||||
bbox_to_anchor=(0.5, -0.12),
|
||||
ncol=2,
|
||||
fontsize=MIN_LABEL_FONT_SIZE,
|
||||
)
|
||||
ax.grid(True, axis="y", alpha=0.3)
|
||||
plt.tight_layout()
|
||||
return self._save(fig, save_path)
|
||||
|
||||
self._save(fig, save_path)
|
||||
return save_path
|
||||
|
||||
@staticmethod
|
||||
def _save(fig: matplotlib.figure.Figure, save_path: str) -> None:
|
||||
"""Save a figure with tight layout at MINI_DPI."""
|
||||
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
fig.savefig(save_path, dpi=MINI_DPI, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone convenience functions (for use by card workers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_cape_chart(years, values, save_path):
|
||||
"""Create historical CAPE trend with current value highlighted."""
|
||||
engine = MiniChartEngine()
|
||||
engine.generate_line_trend(
|
||||
years=years,
|
||||
values=values,
|
||||
title="CAPE Ratio Trend",
|
||||
save_path=save_path,
|
||||
highlight_year=years[-1],
|
||||
highlight_value=values[-1],
|
||||
color=GRAY_DARK,
|
||||
)
|
||||
|
||||
|
||||
def create_capex_chart(companies, values, save_path):
|
||||
"""Create hyperscaler capex comparison bar chart."""
|
||||
engine = MiniChartEngine()
|
||||
colors_list = [AI_SPEND, WARNING_ZONE, REVENUE, GRAY_MEDIUM, BUBBLE_ZONE]
|
||||
engine.generate_horizontal_bar(
|
||||
categories=companies,
|
||||
values=values,
|
||||
title="Hyperscaler AI Capex",
|
||||
save_path=save_path,
|
||||
colors=colors_list[: len(companies)],
|
||||
value_labels=[f"${v}B" for v in values],
|
||||
max_value=max(values) * 1.3,
|
||||
)
|
||||
|
||||
|
||||
def create_utilization_chart(percentage, context_text, save_path):
|
||||
"""Create GPU utilization gauge chart."""
|
||||
engine = MiniChartEngine()
|
||||
engine.generate_utilization_bar(
|
||||
label="GPU Utilization",
|
||||
percentage=percentage,
|
||||
title="Current GPU Utilization",
|
||||
save_path=save_path,
|
||||
context_text=context_text,
|
||||
)
|
||||
|
||||
|
||||
def create_vulnerability_chart(ai_rate, non_ai_rate, save_path):
|
||||
"""Create AI vs non-AI code vulnerability comparison."""
|
||||
engine = MiniChartEngine()
|
||||
engine.generate_comparison_bar(
|
||||
categories=["Code Vulnerability Rate"],
|
||||
values_left=[ai_rate],
|
||||
values_right=[non_ai_rate],
|
||||
title="AI vs Non-AI Code Vulnerability",
|
||||
save_path=save_path,
|
||||
label_left="AI-Generated Code",
|
||||
label_right="Human-Generated Code",
|
||||
colors=[BUBBLE_ZONE, GRAY_MEDIUM],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -213,61 +340,64 @@ class MiniChartEngine:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_tests() -> None:
|
||||
"""Generate 4 placeholder test charts to output/battlecards/charts/."""
|
||||
"""Generate 4 test charts demonstrating each chart type."""
|
||||
base_dir = Path("output/battlecards/charts")
|
||||
engine = MiniChartEngine()
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Test 1: Line trend
|
||||
line_data = [
|
||||
{"x": f"Q{i}", "y": 10 + i * 3} for i in range(1, 5)
|
||||
]
|
||||
# Test 1: Line trend — CAPE-like trend with 2026 highlighted
|
||||
years = [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026]
|
||||
cape_values = [18.2, 28.5, 31.1, 25.3, 22.7, 33.8, 37.2, 41.5, 44.8]
|
||||
path1 = engine.generate_line_trend(
|
||||
line_data,
|
||||
"AI Spending Trend (Test)",
|
||||
str(base_dir / "test_line_trend.png"),
|
||||
years=years,
|
||||
values=cape_values,
|
||||
title="S&P 500 CAPE Ratio Trend",
|
||||
save_path=str(base_dir / "test_line_trend.png"),
|
||||
highlight_year=2026,
|
||||
highlight_value="44.8x",
|
||||
color=GRAY_DARK,
|
||||
)
|
||||
print(f" Line trend: {path1}")
|
||||
print(f" [1/4] Line trend: {path1}")
|
||||
|
||||
# Test 2: Horizontal bar
|
||||
bar_data = [
|
||||
{"label": "Cloud A", "value": 85},
|
||||
{"label": "Cloud B", "value": 62},
|
||||
{"label": "On-prem", "value": 28},
|
||||
]
|
||||
# Test 2: Horizontal bar — Hyperscaler capex comparison
|
||||
companies = ["Microsoft", "Amazon", "Google", "Meta"]
|
||||
capex_values = [234, 118, 90, 72]
|
||||
capex_colors = ["#00a4ef", "#ff9900", "#4285f4", "#1877f2"]
|
||||
path2 = engine.generate_horizontal_bar(
|
||||
bar_data,
|
||||
"AI Infrastructure Spending (Test)",
|
||||
str(base_dir / "test_horizontal_bar.png"),
|
||||
categories=companies,
|
||||
values=capex_values,
|
||||
title="2025 AI Infrastructure Capex ($B)",
|
||||
save_path=str(base_dir / "test_horizontal_bar.png"),
|
||||
colors=capex_colors,
|
||||
value_labels=[f"${v}B" for v in capex_values],
|
||||
max_value=280,
|
||||
)
|
||||
print(f" Horizontal bar: {path2}")
|
||||
print(f" [2/4] Horizontal bar: {path2}")
|
||||
|
||||
# Test 3: Utilization bar
|
||||
util_data = [
|
||||
{"label": "Company A", "value": 18, "max_value": 100},
|
||||
{"label": "Company B", "value": 24, "max_value": 100},
|
||||
{"label": "Company C", "value": 45, "max_value": 100},
|
||||
]
|
||||
# Test 3: Utilization bar — GPU utilization gauge at 5%
|
||||
path3 = engine.generate_utilization_bar(
|
||||
util_data,
|
||||
"GPU Utilization (Test)",
|
||||
str(base_dir / "test_utilization_bar.png"),
|
||||
label="Enterprise Average",
|
||||
percentage=5.0,
|
||||
title="GPU Utilization Rate",
|
||||
save_path=str(base_dir / "test_utilization_bar.png"),
|
||||
context_text="Most GPUs sit idle — 95% capacity wasted",
|
||||
)
|
||||
print(f" Utilization bar: {path3}")
|
||||
print(f" [3/4] Utilization bar: {path3}")
|
||||
|
||||
# Test 4: Comparison bar
|
||||
comp_data = [
|
||||
{"label": "2023", "value_a": 50, "value_b": 30},
|
||||
{"label": "2024", "value_a": 75, "value_b": 45},
|
||||
{"label": "2025", "value_a": 90, "value_b": 55},
|
||||
]
|
||||
# Test 4: Comparison bar — AI vs non-AI vulnerability rates
|
||||
path4 = engine.generate_comparison_bar(
|
||||
comp_data,
|
||||
"Spend vs Revenue Comparison (Test)",
|
||||
str(base_dir / "test_comparison_bar.png"),
|
||||
categories=["Vulnerability Rate (%)"],
|
||||
values_left=[47],
|
||||
values_right=[12],
|
||||
title="Code Vulnerability Comparison",
|
||||
save_path=str(base_dir / "test_comparison_bar.png"),
|
||||
label_left="AI-Generated Code",
|
||||
label_right="Human-Generated Code",
|
||||
colors=[BUBBLE_ZONE, GRAY_MEDIUM],
|
||||
)
|
||||
print(f" Comparison bar: {path4}")
|
||||
print(f" [4/4] Comparison bar: {path4}")
|
||||
|
||||
print("All 4 test charts generated successfully.")
|
||||
print("\nAll 4 test charts generated successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -277,7 +407,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
action="store_true",
|
||||
help="Generate 4 placeholder test charts",
|
||||
help="Generate 4 test charts demonstrating each chart type",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||