NapkinAstella / napkin_plot.py
vitorjuliatto
fix(single-value): quando low==high, usar 0 como mínimo lógico; per-metric usa axis_min=0
8fa445b
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from matplotlib.patches import Circle # kept for potential future use
# Astella Brand Colors (Complete Palette)
COLORS = {
# Primary Colors (Cyan-Turquoise Gradient)
"deep_ocean": "#225379", # Dark text, contrasts
"marine_blue": "#3981A4", # Mid-tone elements
"turquoise": "#56BBC2", # PRIMARY brand accent, CTAs, highlights
"soft_aqua": "#B7E7DC", # Light backgrounds, subtle accents
"mint_whisper": "#E6F7E8", # Very light backgrounds
"pale_sage": "#E2ECCB", # Neutral light backgrounds
# Secondary Colors (Use sparingly)
"coral": "#E05145", # Error states, urgent CTAs
"peach": "#F3AF8A", # Warm accents, success states
}
# Typography preferences (non-failing, will fallback if fonts not available)
plt.rcParams["font.family"] = "sans-serif"
plt.rcParams["font.sans-serif"] = [
"Open Sans",
"Intelo",
"Montserrat",
"Arial",
"Helvetica",
"DejaVu Sans",
]
def _normalize_value(
value: float,
benchmark: float,
metric_type: str = "higher_better",
*,
low: float | None = None,
high: float | None = None,
) -> float:
"""
Normaliza valores 0-100 usando a faixa Napkin:
- Low -> ~60; High -> ~80; abaixo de Low em [40,60), acima de High em (80,100] com compressão log.
"""
if metric_type != "higher_better":
return (value / benchmark) * 100 if benchmark != 0 else 0
low_val = 0.0 if low is None else float(low)
high_val = benchmark if high is None else float(high)
# Caso low==high: ancorar a 0 para evitar distorção (ex.: percentuais)
if low is not None and high is not None and float(low) == float(high):
low_val = 0.0
high_val = float(high)
if high_val <= 0:
return 100 if value > 0 else 40
if low_val <= 0:
if value <= 0:
return 40
if value <= high_val:
return 40 + 40 * (value / high_val)
over = value / high_val
return min(100, 80 + 20 * (np.log1p(over - 1) / np.log1p(9)))
if value <= low_val:
return 40 + 20 * (max(value, 0.0) / low_val)
if value < high_val:
return 60 + 20 * ((value - low_val) / (high_val - low_val))
over = value / high_val
return min(100, 80 + 20 * (np.log1p(over - 1) / np.log1p(9)))
def _check_label_overlap(purple_value: float, napkin_value: float, threshold: float = 12):
"""Detecta sobreposição entre labels e calcula offsets necessários."""
distance = abs(purple_value - napkin_value)
if distance < threshold:
if distance < 7:
radial_offset = (threshold - distance) * 1.3 + 8
elif distance < 9:
radial_offset = (threshold - distance) * 1.1 + 6
else:
radial_offset = (threshold - distance) * 0.9 + 4
direction = "down" if napkin_value < purple_value else "up"
if distance < 7:
angular_offset = 0.18 if napkin_value < purple_value else -0.18
elif distance < 9:
angular_offset = 0.12 if napkin_value < purple_value else -0.12
else:
angular_offset = 0.08 if napkin_value < purple_value else -0.08
return radial_offset, angular_offset, direction
return 0, 0, None
DEFAULT_METRIC_ORDER = ["ARR", "Growth", "Round Size", "Cap Table", "Valuation", "Gross Margin"]
def build_figure(
startup_metrics: dict,
napkin_low: dict,
napkin_high: dict,
*,
metric_order: list | None = None,
startup_name: str = "Startup",
) -> Figure:
"""
Constrói e retorna a Figure do gráfico radar no tema Astella.
Espera dicionários com chaves: 'ARR', 'Growth', 'Round Size', 'Valuation', 'Cap Table', 'Gross Margin'
"""
order = metric_order or DEFAULT_METRIC_ORDER
# Normalização
purple_normalized: list[float] = []
napkin_low_normalized: list[float] = []
napkin_high_normalized: list[float] = []
for metric in order:
benchmark = (napkin_low[metric] + napkin_high[metric]) / 2
p_val = _normalize_value(
startup_metrics[metric],
benchmark,
"higher_better",
low=napkin_low[metric],
high=napkin_high[metric],
)
l_val = _normalize_value(
napkin_low[metric],
benchmark,
"higher_better",
low=napkin_low[metric],
high=napkin_high[metric],
)
h_val = _normalize_value(
napkin_high[metric],
benchmark,
"higher_better",
low=napkin_low[metric],
high=napkin_high[metric],
)
purple_normalized.append(min(100, p_val))
napkin_low_normalized.append(min(100, l_val))
napkin_high_normalized.append(min(100, h_val))
# Figura
fig = plt.figure(figsize=(14, 14), facecolor="white")
ax = fig.add_subplot(111, projection="polar", facecolor="white")
num_vars = len(order)
angles = np.linspace(0, 2 * np.pi, num_vars, endpoint=False).tolist()
purple_plot = purple_normalized + [purple_normalized[0]]
napkin_low_plot = napkin_low_normalized + [napkin_low_normalized[0]]
napkin_high_plot = napkin_high_normalized + [napkin_high_normalized[0]]
angles += angles[:1]
# Configurações polares
ax.set_ylim(0, 100)
ax.set_theta_offset(np.pi / 2)
ax.set_theta_direction(-1)
ax.set_yticklabels([])
ax.grid(True, color="#E0E0E0", linestyle="-", linewidth=1.2, alpha=0.6)
# Linha externa mais visível
theta_circle = np.linspace(0, 2 * np.pi, 200)
r_circle = np.full_like(theta_circle, 100)
ax.plot(theta_circle, r_circle, color="#C0C0C0", linewidth=2.5, alpha=0.7, zorder=1)
# Faixa de benchmark
ax.fill_between(
angles, napkin_low_plot, napkin_high_plot, color=COLORS["marine_blue"], alpha=0.15, zorder=1
)
# Linhas Low/High
ax.plot(
angles,
napkin_low_plot,
color=COLORS["marine_blue"],
linewidth=1.8,
linestyle=":",
alpha=0.5,
zorder=2,
)
ax.plot(
angles,
napkin_high_plot,
color=COLORS["marine_blue"],
linewidth=1.8,
linestyle=":",
alpha=0.5,
zorder=2,
)
# Labels Low
for i, (angle, value, metric) in enumerate(zip(angles[:-1], napkin_low_normalized, order)):
purple_value = purple_normalized[i]
radial_offset, angular_offset, direction = _check_label_overlap(purple_value, value)
if radial_offset > 0:
label_distance = max(0, value - radial_offset) if direction == "down" else min(100, value + radial_offset)
else:
label_distance = value
adjusted_angle = angle + angular_offset
if metric == "ARR":
napkin_text = f'${napkin_low["ARR"]}M'
elif metric == "Growth":
napkin_text = f'{int(napkin_low["Growth"])}%'
elif metric == "Round Size":
napkin_text = f'${napkin_low["Round Size"]}M'
elif metric == "Valuation":
napkin_text = f'${napkin_low["Valuation"]}M'
elif metric == "Cap Table":
napkin_text = f'{int(napkin_low["Cap Table"])}%'
else:
napkin_text = f'{int(napkin_low["Gross Margin"])}%'
ha_align = "left" if angular_offset > 0 else ("right" if angular_offset < 0 else "center")
ax.text(
adjusted_angle,
label_distance,
napkin_text,
ha=ha_align,
va="center",
fontsize=14,
fontweight="500",
color=COLORS["marine_blue"],
bbox=dict(
boxstyle="round,pad=0.3",
facecolor="white",
edgecolor=COLORS["marine_blue"],
linewidth=1.2,
alpha=0.85,
),
zorder=5,
)
# Labels High
for i, (angle, value, metric) in enumerate(zip(angles[:-1], napkin_high_normalized, order)):
purple_value = purple_normalized[i]
radial_offset, angular_offset, direction = _check_label_overlap(purple_value, value)
if radial_offset > 0:
label_distance = max(0, value - radial_offset) if direction == "down" else min(100, value + radial_offset)
else:
label_distance = value
adjusted_angle = angle + angular_offset
if metric == "ARR":
napkin_text = f'${napkin_high["ARR"]}M'
elif metric == "Growth":
napkin_text = f'{int(napkin_high["Growth"])}%'
elif metric == "Round Size":
napkin_text = f'${napkin_high["Round Size"]}M'
elif metric == "Valuation":
napkin_text = f'${napkin_high["Valuation"]}M'
elif metric == "Cap Table":
napkin_text = f'{int(napkin_high["Cap Table"])}%'
else:
napkin_text = f'{int(napkin_high["Gross Margin"])}%'
ha_align = "left" if angular_offset > 0 else ("right" if angular_offset < 0 else "center")
ax.text(
adjusted_angle,
label_distance,
napkin_text,
ha=ha_align,
va="center",
fontsize=14,
fontweight="500",
color=COLORS["marine_blue"],
bbox=dict(
boxstyle="round,pad=0.3",
facecolor="white",
edgecolor=COLORS["marine_blue"],
linewidth=1.2,
alpha=0.85,
),
zorder=5,
)
# Linha principal Purple
ax.plot(angles, purple_plot, color=COLORS["turquoise"], linewidth=4.5, linestyle="-", zorder=4)
ax.fill(angles, purple_plot, color=COLORS["turquoise"], alpha=0.25, zorder=3)
# Pontos em destaque
for i, (angle, value, metric) in enumerate(zip(angles[:-1], purple_normalized, order)):
ax.plot(angle, value, "o", color=COLORS["turquoise"], markersize=18, markeredgewidth=3.5, markeredgecolor="white", zorder=5)
ax.plot(angle, value, "o", color=COLORS["turquoise"], markersize=18, alpha=0.35, zorder=4.5)
if metric == "ARR":
label_text = f'${startup_metrics["ARR"]}M'
elif metric == "Growth":
label_text = f'{startup_metrics["Growth"]}%'
elif metric == "Round Size":
label_text = f'${startup_metrics["Round Size"]}M'
elif metric == "Valuation":
label_text = f'${startup_metrics["Valuation"]}M'
elif metric == "Cap Table":
label_text = f'{startup_metrics["Cap Table"]}%'
else:
label_text = f'{int(startup_metrics["Gross Margin"])}%'
ax.text(
angle,
value,
label_text,
ha="center",
va="center",
fontsize=15,
fontweight="bold",
color=COLORS["deep_ocean"],
bbox=dict(
boxstyle="round,pad=0.45",
facecolor="white",
edgecolor=COLORS["turquoise"],
linewidth=2.5,
alpha=0.98,
),
zorder=6,
)
# Eixos e labels externos
ax.set_xticks(angles[:-1])
ax.set_xticklabels([])
for angle, label in zip(angles[:-1], order):
rotation = np.rad2deg(angle) # noqa: F841 (mantido para referência futura)
if label == "ARR":
ha, distance_mul = "center", 1.10
elif angle == 0:
ha, distance_mul = "center", 1.13
elif 0 < angle < np.pi:
ha, distance_mul = "left", 1.13
elif angle == np.pi:
ha, distance_mul = "center", 1.17
else:
ha, distance_mul = "right", 1.13
ax.text(
angle,
100 * distance_mul,
label,
ha=ha,
va="center",
fontsize=18,
fontweight="bold",
color=COLORS["deep_ocean"],
linespacing=1.3,
)
# Remover borda circular
ax.spines["polar"].set_visible(False)
# Legenda
legend_y = 0.09
legend_x_start = 0.18
# Série principal (Startup)
fig.patches.append(
plt.Rectangle(
(legend_x_start, legend_y),
0.025,
0.012,
transform=fig.transFigure,
facecolor=COLORS["turquoise"],
edgecolor="white",
linewidth=2.5,
)
)
fig.text(
legend_x_start + 0.035,
legend_y + 0.006,
f"{startup_name} Metrics",
transform=fig.transFigure,
fontsize=16,
fontweight="700",
color=COLORS["deep_ocean"],
va="center",
)
# Napkin Low
napkin_low_x = legend_x_start + 0.20
fig.patches.append(
plt.Rectangle(
(napkin_low_x, legend_y),
0.025,
0.012,
transform=fig.transFigure,
facecolor=COLORS["marine_blue"],
edgecolor="white",
linewidth=1.2,
alpha=0.35,
)
)
fig.text(
napkin_low_x + 0.035,
legend_y + 0.006,
"Napkin Low",
transform=fig.transFigure,
fontsize=16,
fontweight="700",
color=COLORS["deep_ocean"],
va="center",
)
# Napkin High
napkin_high_x = legend_x_start + 0.35
fig.patches.append(
plt.Rectangle(
(napkin_high_x, legend_y),
0.025,
0.012,
transform=fig.transFigure,
facecolor=COLORS["marine_blue"],
edgecolor="white",
linewidth=1.2,
alpha=0.35,
)
)
fig.text(
napkin_high_x + 0.035,
legend_y + 0.006,
"Napkin High",
transform=fig.transFigure,
fontsize=16,
fontweight="700",
color=COLORS["deep_ocean"],
va="center",
)
# Nota de rodapé
fig.text(
0.5,
0.04,
"Napkin Benchmark: ARR $0.64M-$1.83M | Growth 200% | Round $1.46M-$3.66M | Valuation $5.86M-$10.9M | Cap Table 80% | Gross Margin 70%",
ha="center",
va="center",
fontsize=13.5,
color=COLORS["marine_blue"],
style="italic",
transform=fig.transFigure,
)
# Margens
plt.subplots_adjust(left=0.1, right=0.9, top=0.93, bottom=0.20)
return fig