Burbuja Gráficos Elegantemente comprimir grandes cantidades de información en una sola visualización, con el tamaño de la burbuja agregando una tercera dimensión. Sin embargo, comparar los estados “antes” y “después” a menudo es crucial. Para abordar esto, proponemos agregar una transición entre estos estados, creando una experiencia de usuario intuitiva.
Como no pudimos encontrar una solución preparada, desarrollamos la nuestra. El desafío resultó ser fascinante y requirió refrescar algunos conceptos matemáticos.
Sin lugar a dudas, la parte más desafiante de la visualización es la transición entre dos círculos, antes y después de los estados. Para simplificar, nos centramos en resolver un solo caso, que luego se puede extender en un bucle para generar el número necesario de transiciones.
Para construir dicha figura, primero descompongamos en tres partes: dos círculos y un polígono que los conecta (en gris).

Construir dos círculos es bastante simple: conocemos sus centros y radios. La tarea restante es construir un polígono cuadrilátero, que tiene la siguiente forma:

La construcción de este polígono se reduce a encontrar las coordenadas de sus vértices. Esta es la tarea más interesante, y la resolveremos más.

Para calcular la distancia desde un punto (x1, y1) a la línea AX+Y+B = 0la fórmula es:

En nuestro caso, distancia (d) es igual al radio del círculo (riñonal). Por eso,

Después de multiplicar ambos lados de la ecuación por A ** 2+1obtenemos:

Después de mover todo a un lado y establecer la ecuación igual a cero, obtenemos:

Como tenemos dos círculos y necesitamos encontrar una tangente para ambos, tenemos el siguiente sistema de ecuaciones:

Esto funciona muy bien, pero el problema es que tenemos 4 posibles líneas tangentes en la realidad:

Y necesitamos elegir solo 2 de ellos: los externos.
Para hacer esto, necesitamos verificar cada tangente y cada centro de círculo y determinar si la línea está por encima o por debajo del punto:

Necesitamos las dos líneas que pasan arriba o ambas pasan por debajo de los centros de los círculos.
Ahora, traduzcamos todos estos pasos al código:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import sympy as sp
from scipy.spatial import ConvexHull
import math
from matplotlib import rcParams
import matplotlib.patches as patches
def check_position_relative_to_line(a, b, x0, y0):
y_line = a * x0 + b
if y0 > y_line:
return 1 # line is above the point
elif y0 < y_line:
return -1
def find_tangent_equations(x1, y1, r1, x2, y2, r2):
a, b = sp.symbols('a b')
tangent_1 = (a*x1 + b - y1)**2 - r1**2 * (a**2 + 1)
tangent_2 = (a*x2 + b - y2)**2 - r2**2 * (a**2 + 1)
eqs_1 = [tangent_2, tangent_1]
solution = sp.solve(eqs_1, (a, b))
parameters = [(float(e[0]), float(e[1])) for e in solution]
# filter just external tangents
parameters_filtered = []
for tangent in parameters:
a = tangent[0]
b = tangent[1]
if abs(check_position_relative_to_line(a, b, x1, y1) + check_position_relative_to_line(a, b, x2, y2)) == 2:
parameters_filtered.append(tangent)
return parameters_filtered
Ahora, solo necesitamos encontrar las intersecciones de las tangentes con los círculos. Estos 4 puntos serán los vértices del polígono deseado.
Ecuación de círculo:

Sustituir la ecuación de línea y = ax+b en la ecuación del círculo:

La solución de la ecuación es la incógnita de la intersección.
Entonces, calcule Y De la ecuación de línea:

Cómo se traduce en el código:
def find_circle_line_intersection(circle_x, circle_y, circle_r, line_a, line_b):
x, y = sp.symbols('x y')
circle_eq = (x - circle_x)**2 + (y - circle_y)**2 - circle_r**2
intersection_eq = circle_eq.subs(y, line_a * x + line_b)
sol_x_raw = sp.solve(intersection_eq, x)[0]
try:
sol_x = float(sol_x_raw)
except:
sol_x = sol_x_raw.as_real_imag()[0]
sol_y = line_a * sol_x + line_b
return sol_x, sol_y
Ahora queremos generar datos de muestra para demostrar las composiciones completas.
Imagine que tenemos 4 usuarios en nuestra plataforma. Sabemos cuántas compras hicieron, generaron ingresos y actividades en la plataforma. Todas estas métricas se calculan para 2 períodos (llamémoslas antes y después del período).
# data generation
df = pd.DataFrame({'user': ['Emily', 'Emily', 'James', 'James', 'Tony', 'Tony', 'Olivia', 'Olivia'],
'period': ['pre', 'post', 'pre', 'post', 'pre', 'post', 'pre', 'post'],
'num_purchases': [10, 9, 3, 5, 2, 4, 8, 7],
'revenue': [70, 60, 80, 90, 20, 15, 80, 76],
'activity': [100, 80, 50, 90, 210, 170, 60, 55]})

Supongamos que la “actividad” es el área de la burbuja. Ahora, convímímoslo en el radio de la burbuja. También escalaremos el eje y.
def area_to_radius(area):
radius = math.sqrt(area / math.pi)
return radius
x_alias, y_alias, a_alias="num_purchases", 'revenue', 'activity'
# scaling metrics
radius_scaler = 0.1
df['radius'] = df[a_alias].apply(area_to_radius) * radius_scaler
df['y_scaled'] = df[y_alias] / df[x_alias].max()
Ahora construamos el gráfico: 2 círculos y el polígono.
def draw_polygon(plt, points):
hull = ConvexHull(points)
convex_points = [points[i] for i in hull.vertices]
x, y = zip(*convex_points)
x += (x[0],)
y += (y[0],)
plt.fill(x, y, color="#99d8e1", alpha=1, zorder=1)
# bubble pre
for _, row in df[df.period=='pre'].iterrows():
x = row[x_alias]
y = row.y_scaled
r = row.radius
circle = patches.Circle((x, y), r, facecolor="#99d8e1", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
# transition area
for user in df.user.unique():
user_pre = df[(df.user==user) & (df.period=='pre')]
x1, y1, r1 = user_pre[x_alias].values[0], user_pre.y_scaled.values[0], user_pre.radius.values[0]
user_post = df[(df.user==user) & (df.period=='post')]
x2, y2, r2 = user_post[x_alias].values[0], user_post.y_scaled.values[0], user_post.radius.values[0]
tangent_equations = find_tangent_equations(x1, y1, r1, x2, y2, r2)
circle_1_line_intersections = [find_circle_line_intersection(x1, y1, r1, eq[0], eq[1]) for eq in tangent_equations]
circle_2_line_intersections = [find_circle_line_intersection(x2, y2, r2, eq[0], eq[1]) for eq in tangent_equations]
polygon_points = circle_1_line_intersections + circle_2_line_intersections
draw_polygon(plt, polygon_points)
# bubble post
for _, row in df[df.period=='post'].iterrows():
x = row[x_alias]
y = row.y_scaled
r = row.radius
label = row.user
circle = patches.Circle((x, y), r, facecolor="#2d699f", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
plt.text(x, y - r - 0.3, label, fontsize=12, ha="center")
La salida se ve como se esperaba:

Ahora queremos agregar algo de estilo:
# plot parameters
plt.subplots(figsize=(10, 10))
rcParams['font.family'] = 'DejaVu Sans'
rcParams['font.size'] = 14
plt.grid(color="gray", linestyle=(0, (10, 10)), linewidth=0.5, alpha=0.6, zorder=1)
plt.axvline(x=0, color="white", linewidth=2)
plt.gca().set_facecolor('white')
plt.gcf().set_facecolor('white')
# spines formatting
plt.gca().spines["top"].set_visible(False)
plt.gca().spines["right"].set_visible(False)
plt.gca().spines["bottom"].set_visible(False)
plt.gca().spines["left"].set_visible(False)
plt.gca().tick_params(axis="both", which="both", length=0)
# plot labels
plt.xlabel("Number purchases")
plt.ylabel("Revenue, $")
plt.title("Product users performance", fontsize=18, color="black")
# axis limits
axis_lim = df[x_alias].max() * 1.2
plt.xlim(0, axis_lim)
plt.ylim(0, axis_lim)
Leyenda previa al post en la esquina inferior derecha para darle a espectador una pista, cómo leer el gráfico:
## pre-post legend
# circle 1
legend_position, r1 = (11, 2.2), 0.3
x1, y1 = legend_position[0], legend_position[1]
circle = patches.Circle((x1, y1), r1, facecolor="#99d8e1", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
plt.text(x1, y1 + r1 + 0.15, 'Pre', fontsize=12, ha="center", va="center")
# circle 2
x2, y2 = legend_position[0], legend_position[1] - r1*3
r2 = r1*0.7
circle = patches.Circle((x2, y2), r2, facecolor="#2d699f", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
plt.text(x2, y2 - r2 - 0.15, 'Post', fontsize=12, ha="center", va="center")
# tangents
tangent_equations = find_tangent_equations(x1, y1, r1, x2, y2, r2)
circle_1_line_intersections = [find_circle_line_intersection(x1, y1, r1, eq[0], eq[1]) for eq in tangent_equations]
circle_2_line_intersections = [find_circle_line_intersection(x2, y2, r2, eq[0], eq[1]) for eq in tangent_equations]
polygon_points = circle_1_line_intersections + circle_2_line_intersections
draw_polygon(plt, polygon_points)
# small arrow
plt.annotate('', xytext=(x1, y1), xy=(x2, y1 - r1*2), arrowprops=dict(edgecolor="black", arrowstyle="->", lw=1))

Y finalmente la leyenda del tamaño de la burbuja:
# bubble size legend
legend_areas_original = [150, 50]
legend_position = (11, 10.2)
for i in legend_areas_original:
i_r = area_to_radius(i) * radius_scaler
circle = plt.Circle((legend_position[0], legend_position[1] + i_r), i_r, color="black", fill=False, linewidth=0.6, facecolor="none")
plt.gca().add_patch(circle)
plt.text(legend_position[0], legend_position[1] + 2*i_r, str(i), fontsize=12, ha="center", va="center",
bbox=dict(facecolor="white", edgecolor="none", boxstyle="round,pad=0.1"))
legend_label_r = area_to_radius(np.max(legend_areas_original)) * radius_scaler
plt.text(legend_position[0], legend_position[1] + 2*legend_label_r + 0.3, 'Activity, hours', fontsize=12, ha="center", va="center")
Nuestro cuadro final se ve así:

La visualización se ve muy elegante y concentra mucha información en forma compacta.
Aquí está el código completo para el gráfico:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import sympy as sp
from scipy.spatial import ConvexHull
import math
from matplotlib import rcParams
import matplotlib.patches as patches
def check_position_relative_to_line(a, b, x0, y0):
y_line = a * x0 + b
if y0 > y_line:
return 1 # line is above the point
elif y0 < y_line:
return -1
def find_tangent_equations(x1, y1, r1, x2, y2, r2):
a, b = sp.symbols('a b')
tangent_1 = (a*x1 + b - y1)**2 - r1**2 * (a**2 + 1)
tangent_2 = (a*x2 + b - y2)**2 - r2**2 * (a**2 + 1)
eqs_1 = [tangent_2, tangent_1]
solution = sp.solve(eqs_1, (a, b))
parameters = [(float(e[0]), float(e[1])) for e in solution]
# filter just external tangents
parameters_filtered = []
for tangent in parameters:
a = tangent[0]
b = tangent[1]
if abs(check_position_relative_to_line(a, b, x1, y1) + check_position_relative_to_line(a, b, x2, y2)) == 2:
parameters_filtered.append(tangent)
return parameters_filtered
def find_circle_line_intersection(circle_x, circle_y, circle_r, line_a, line_b):
x, y = sp.symbols('x y')
circle_eq = (x - circle_x)**2 + (y - circle_y)**2 - circle_r**2
intersection_eq = circle_eq.subs(y, line_a * x + line_b)
sol_x_raw = sp.solve(intersection_eq, x)[0]
try:
sol_x = float(sol_x_raw)
except:
sol_x = sol_x_raw.as_real_imag()[0]
sol_y = line_a * sol_x + line_b
return sol_x, sol_y
def draw_polygon(plt, points):
hull = ConvexHull(points)
convex_points = [points[i] for i in hull.vertices]
x, y = zip(*convex_points)
x += (x[0],)
y += (y[0],)
plt.fill(x, y, color="#99d8e1", alpha=1, zorder=1)
def area_to_radius(area):
radius = math.sqrt(area / math.pi)
return radius
# data generation
df = pd.DataFrame({'user': ['Emily', 'Emily', 'James', 'James', 'Tony', 'Tony', 'Olivia', 'Olivia', 'Oliver', 'Oliver', 'Benjamin', 'Benjamin'],
'period': ['pre', 'post', 'pre', 'post', 'pre', 'post', 'pre', 'post', 'pre', 'post', 'pre', 'post'],
'num_purchases': [10, 9, 3, 5, 2, 4, 8, 7, 6, 7, 4, 6],
'revenue': [70, 60, 80, 90, 20, 15, 80, 76, 17, 19, 45, 55],
'activity': [100, 80, 50, 90, 210, 170, 60, 55, 30, 20, 200, 120]})
x_alias, y_alias, a_alias="num_purchases", 'revenue', 'activity'
# scaling metrics
radius_scaler = 0.1
df['radius'] = df[a_alias].apply(area_to_radius) * radius_scaler
df['y_scaled'] = df[y_alias] / df[x_alias].max()
# plot parameters
plt.subplots(figsize=(10, 10))
rcParams['font.family'] = 'DejaVu Sans'
rcParams['font.size'] = 14
plt.grid(color="gray", linestyle=(0, (10, 10)), linewidth=0.5, alpha=0.6, zorder=1)
plt.axvline(x=0, color="white", linewidth=2)
plt.gca().set_facecolor('white')
plt.gcf().set_facecolor('white')
# spines formatting
plt.gca().spines["top"].set_visible(False)
plt.gca().spines["right"].set_visible(False)
plt.gca().spines["bottom"].set_visible(False)
plt.gca().spines["left"].set_visible(False)
plt.gca().tick_params(axis="both", which="both", length=0)
# plot labels
plt.xlabel("Number purchases")
plt.ylabel("Revenue, $")
plt.title("Product users performance", fontsize=18, color="black")
# axis limits
axis_lim = df[x_alias].max() * 1.2
plt.xlim(0, axis_lim)
plt.ylim(0, axis_lim)
# bubble pre
for _, row in df[df.period=='pre'].iterrows():
x = row[x_alias]
y = row.y_scaled
r = row.radius
circle = patches.Circle((x, y), r, facecolor="#99d8e1", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
# transition area
for user in df.user.unique():
user_pre = df[(df.user==user) & (df.period=='pre')]
x1, y1, r1 = user_pre[x_alias].values[0], user_pre.y_scaled.values[0], user_pre.radius.values[0]
user_post = df[(df.user==user) & (df.period=='post')]
x2, y2, r2 = user_post[x_alias].values[0], user_post.y_scaled.values[0], user_post.radius.values[0]
tangent_equations = find_tangent_equations(x1, y1, r1, x2, y2, r2)
circle_1_line_intersections = [find_circle_line_intersection(x1, y1, r1, eq[0], eq[1]) for eq in tangent_equations]
circle_2_line_intersections = [find_circle_line_intersection(x2, y2, r2, eq[0], eq[1]) for eq in tangent_equations]
polygon_points = circle_1_line_intersections + circle_2_line_intersections
draw_polygon(plt, polygon_points)
# bubble post
for _, row in df[df.period=='post'].iterrows():
x = row[x_alias]
y = row.y_scaled
r = row.radius
label = row.user
circle = patches.Circle((x, y), r, facecolor="#2d699f", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
plt.text(x, y - r - 0.3, label, fontsize=12, ha="center")
# bubble size legend
legend_areas_original = [150, 50]
legend_position = (11, 10.2)
for i in legend_areas_original:
i_r = area_to_radius(i) * radius_scaler
circle = plt.Circle((legend_position[0], legend_position[1] + i_r), i_r, color="black", fill=False, linewidth=0.6, facecolor="none")
plt.gca().add_patch(circle)
plt.text(legend_position[0], legend_position[1] + 2*i_r, str(i), fontsize=12, ha="center", va="center",
bbox=dict(facecolor="white", edgecolor="none", boxstyle="round,pad=0.1"))
legend_label_r = area_to_radius(np.max(legend_areas_original)) * radius_scaler
plt.text(legend_position[0], legend_position[1] + 2*legend_label_r + 0.3, 'Activity, hours', fontsize=12, ha="center", va="center")
## pre-post legend
# circle 1
legend_position, r1 = (11, 2.2), 0.3
x1, y1 = legend_position[0], legend_position[1]
circle = patches.Circle((x1, y1), r1, facecolor="#99d8e1", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
plt.text(x1, y1 + r1 + 0.15, 'Pre', fontsize=12, ha="center", va="center")
# circle 2
x2, y2 = legend_position[0], legend_position[1] - r1*3
r2 = r1*0.7
circle = patches.Circle((x2, y2), r2, facecolor="#2d699f", edgecolor="none", linewidth=0, zorder=2)
plt.gca().add_patch(circle)
plt.text(x2, y2 - r2 - 0.15, 'Post', fontsize=12, ha="center", va="center")
# tangents
tangent_equations = find_tangent_equations(x1, y1, r1, x2, y2, r2)
circle_1_line_intersections = [find_circle_line_intersection(x1, y1, r1, eq[0], eq[1]) for eq in tangent_equations]
circle_2_line_intersections = [find_circle_line_intersection(x2, y2, r2, eq[0], eq[1]) for eq in tangent_equations]
polygon_points = circle_1_line_intersections + circle_2_line_intersections
draw_polygon(plt, polygon_points)
# small arrow
plt.annotate('', xytext=(x1, y1), xy=(x2, y1 - r1*2), arrowprops=dict(edgecolor="black", arrowstyle="->", lw=1))
# y axis formatting
max_y = df[y_alias].max()
nearest_power_of_10 = 10 ** math.ceil(math.log10(max_y))
ticks = [round(nearest_power_of_10/5 * i, 2) for i in range(0, 6)]
yticks_scaled = ticks / df[x_alias].max()
yticklabels = [str(i) for i in ticks]
yticklabels[0] = ''
plt.yticks(yticks_scaled, yticklabels)
plt.savefig("plot_with_white_background.png", bbox_inches="tight", dpi=300)
Agregar una dimensión de tiempo a los gráficos de burbujas mejora su capacidad para transmitir cambios de datos dinámicos intuitivamente. Al implementar transiciones sin problemas entre los estados “antes” y “después”, los usuarios pueden comprender mejor las tendencias y las comparaciones con el tiempo.
Si bien no había soluciones preparadas disponibles, el desarrollo de un enfoque personalizado demostró ser desafiante y gratificante, requiriendo ideas matemáticas y técnicas de animación cuidadosas. El método propuesto se puede extender fácilmente a varios conjuntos de datos, por lo que es una herramienta valiosa para Visualización de datos en negocios, ciencias y análisis.