2025, Dec 21 21:02
Как выровнять подписи на 3D‑столбцах Matplotlib bar3d
Как исправить смещение подписей на 3D‑столбчатых диаграммах Matplotlib bar3d: единое выравнивание и небольшой сдвиг по оси z для четких и стабильных меток.
Подписи на 3D‑диаграммах часто ведут себя непредсказуемо: часть аннотаций аккуратно выравнивается под столбцами, а другие заезжают внутрь колонок или «уплывают» в сторону. Когда важно, чтобы каждое значение располагалось сразу под соответствующим столбцом и визуально шло по одной диагонали, малейшие ошибки в позиционировании и выравнивании становятся заметны. В этом материале показан минимальный, точечный прием, который обеспечивает стабильное размещение подписей в Matplotlib bar3d без изменения логики построения графика.
Постановка задачи
Пример ниже воспроизводит ситуацию, когда только одна подпись оказывается правильно выровненной под своим столбцом, а остальные попадают внутрь или на разное расстояние. Код строит составную 3D‑столбчатую диаграмму в Matplotlib и рисует подписи значений для каждого столбца.
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import numpy as np
import pandas as pd
palette_map = {'used': '#A54836', 'free': '#5375D4'}
axis_tick_color, xlab_tone, totals_tone, legend_tone, grid_tone, label_tone = '#101628', "#101628", "#101628", "#101628", "#C8C9C9", "#757C85"
frame = pd.DataFrame([
['hd_xxxxxx-ppp-d2', 355, 237, 592],
['hd_disk_vvvvvvvv', 0, 184, 184],
['hd_aaaa-C_DDDDDDDDDDD', 0, 240, 240],
['hd_AAAAAAA_DDDDDDDDDDD', 1, 9, 10],
['hd_xxxxxx-ppp-d1', 870, 342, 1212],
['hd_aaaa_CC2-SSSS-01', 0, 9, 9],
['hd_aaaa_DD1-MMMM-01', 387, 286, 673],
['hd_wwww_DD2-MMMM-01', 437, 230, 667],
['hd_disk_bbbbbbbbbb', 0, 179, 179],
])
frame.columns = ['disk_pool_name', 'used_space_kb', 'free_space_kb', 'total_capacity']
used_blk = frame[['disk_pool_name', 'used_space_kb']].rename(columns={"used_space_kb": "TB"})
used_blk['status'] = 'used'
free_blk = frame[['disk_pool_name', 'free_space_kb']].rename(columns={"free_space_kb": "TB"})
free_blk['status'] = 'free'
stacked_df = pd.concat([used_blk, free_blk], axis=0, ignore_index=True, sort=False)
stacked_df = stacked_df.sort_values(['disk_pool_name', 'status'], ascending=[True, False])
stacked_df['colors'] = stacked_df.status.map(palette_map)
pools = stacked_df.disk_pool_name.unique()
states = ['used', 'free']
n_states = len(states)
caps_total = frame.sort_values(['disk_pool_name'])['total_capacity']
bar_colors = stacked_df.colors
width_x = 100
width_y = 100
heights = stacked_df.TB.to_list()
step_x = 250
step_y = 125
xpos = np.repeat(np.arange(0, len(pools)) * step_x, len(states)).tolist()
ypos = np.repeat(np.arange(0, len(pools)) * step_y, len(states)).tolist()
base_z = np.array(stacked_df.groupby('disk_pool_name', sort=False).TB.apply(list).tolist())
base_z = np.cumsum(base_z, axis=1)[:, :n_states-1]
base_z = np.insert(base_z, 0, 0, axis=1).flatten().tolist()
canvas = plt.figure(figsize=(10, 8))
axes3d = canvas.add_subplot(1, 1, 1, projection='3d')
axes3d.bar3d(xpos, ypos, base_z, width_x, width_y, heights, color=bar_colors, label=states)
for xb, yb, zb, h in zip(xpos, ypos, base_z, heights):
value = h if h > 0 else ''
axes3d.text(xb - 1.5, yb, zb + h / 2, value, size=10, ha="right", color=label_tone)
legend_handles = [Line2D([0], [0], color=c, marker='s', linestyle='', markersize=10,) for c in reversed(bar_colors.unique())]
legend_labels = stacked_df.status.unique()
plt.legend(legend_handles, reversed(legend_labels), labelcolor=legend_tone,
prop=dict(weight='bold', size=12),
bbox_to_anchor=(0.5, -0.05), loc="lower center",
ncols=2, frameon=False, fontsize=14)
for i, (pool, total_cap) in enumerate(zip(pools, caps_total)):
axes3d.text(xpos[i * n_states] - 100, ypos[i * n_states] - 710, z=-200, zdir='y',
s=f"{pool}", color=axis_tick_color, weight="bold", fontsize=7)
axes3d.text(xpos[i * n_states] - 130, ypos[i * n_states] + 260, z=base_z[i * n_states] + total_cap + 2,
s=f"{total_cap}", fontsize=10, weight="bold", color=totals_tone,
bbox=dict(facecolor='none', edgecolor='#EBEDEE', boxstyle='round,pad=0.3'))
axes3d.set_aspect("equal")
Что на самом деле вызывает смещение
Подписи ставятся исходя из основания столбца, его высоты и настроек выравнивания текста. Когда эти три вещи «не дружат», текст легко оказывается слишком высоко, низко или смещается вбок. Одна лишь подстройка zdir не решит проблему для всех столбцов: воспринимаемое «диагональное» выравнивание в 3D возникает из‑за перспективы камеры и согласованных сдвигов в координатах данных, а не из‑за поворота самой надписи. Ключ — фиксировать подписи одинаково относительно позиции каждого столбца и слегка, на одинаковую величину, сдвигать их вдоль оси z.
Решение: небольшой сдвиг по оси z и единое выравнивание
Размещайте каждую подпись сразу под столбцом, добавляя небольшой отрицательный сдвиг по z, и выравнивайте ее по центру над этой опорной точкой. Так интервал визуально стабилизируется по всем столбцам, а подписи остаются близко и последовательно «по диагонали» относительно перспективы.
axes3d.text(x, y, z - 0.05, # при необходимости подберите небольшое смещение
label_point,
size=10,
ha='center',
va='top',
zdir='z',
color=label_tone)
Ниже — полный код диаграммы с интегрированной корректировкой размещения подписей. Все остальное функционально неизменно.
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import numpy as np
import pandas as pd
palette_map = {'used': '#A54836', 'free': '#5375D4'}
axis_tick_color, xlab_tone, totals_tone, legend_tone, grid_tone, label_tone = '#101628', "#101628", "#101628", "#101628", "#C8C9C9", "#757C85"
frame = pd.DataFrame([
['hd_xxxxxx-ppp-d2', 355, 237, 592],
['hd_disk_vvvvvvvv', 0, 184, 184],
['hd_aaaa-C_DDDDDDDDDDD', 0, 240, 240],
['hd_AAAAAAA_DDDDDDDDDDD', 1, 9, 10],
['hd_xxxxxx-ppp-d1', 870, 342, 1212],
['hd_aaaa_CC2-SSSS-01', 0, 9, 9],
['hd_aaaa_DD1-MMMM-01', 387, 286, 673],
['hd_wwww_DD2-MMMM-01', 437, 230, 667],
['hd_disk_bbbbbbbbbb', 0, 179, 179],
])
frame.columns = ['disk_pool_name', 'used_space_kb', 'free_space_kb', 'total_capacity']
used_blk = frame[['disk_pool_name', 'used_space_kb']].rename(columns={"used_space_kb": "TB"})
used_blk['status'] = 'used'
free_blk = frame[['disk_pool_name', 'free_space_kb']].rename(columns={"free_space_kb": "TB"})
free_blk['status'] = 'free'
stacked_df = pd.concat([used_blk, free_blk], axis=0, ignore_index=True, sort=False)
stacked_df = stacked_df.sort_values(['disk_pool_name', 'status'], ascending=[True, False])
stacked_df['colors'] = stacked_df.status.map(palette_map)
pools = stacked_df.disk_pool_name.unique()
states = ['used', 'free']
n_states = len(states)
caps_total = frame.sort_values(['disk_pool_name'])['total_capacity']
bar_colors = stacked_df.colors
width_x = 100
width_y = 100
heights = stacked_df.TB.to_list()
step_x = 250
step_y = 125
xpos = np.repeat(np.arange(0, len(pools)) * step_x, len(states)).tolist()
ypos = np.repeat(np.arange(0, len(pools)) * step_y, len(states)).tolist()
base_z = np.array(stacked_df.groupby('disk_pool_name', sort=False).TB.apply(list).tolist())
base_z = np.cumsum(base_z, axis=1)[:, :n_states-1]
base_z = np.insert(base_z, 0, 0, axis=1).flatten().tolist()
canvas = plt.figure(figsize=(10, 8))
axes3d = canvas.add_subplot(1, 1, 1, projection='3d')
axes3d.bar3d(xpos, ypos, base_z, width_x, width_y, heights, color=bar_colors, label=states)
for xb, yb, zb, h in zip(xpos, ypos, base_z, heights):
value = h if h > 0 else ''
axes3d.text(xb, yb, zb - 0.05,
value,
size=10,
ha='center',
va='top',
zdir='z',
color=label_tone)
legend_handles = [Line2D([0], [0], color=c, marker='s', linestyle='', markersize=10,) for c in reversed(bar_colors.unique())]
legend_labels = stacked_df.status.unique()
plt.legend(legend_handles, reversed(legend_labels), labelcolor=legend_tone,
prop=dict(weight='bold', size=12),
bbox_to_anchor=(0.5, -0.05), loc="lower center",
ncols=2, frameon=False, fontsize=14)
for i, (pool, total_cap) in enumerate(zip(pools, caps_total)):
axes3d.text(xpos[i * n_states] - 100, ypos[i * n_states] - 710, z=-200, zdir='y',
s=f"{pool}", color=axis_tick_color, weight="bold", fontsize=7)
axes3d.text(xpos[i * n_states] - 130, ypos[i * n_states] + 260, z=base_z[i * n_states] + total_cap + 2,
s=f"{total_cap}", fontsize=10, weight="bold", color=totals_tone,
bbox=dict(facecolor='none', edgecolor='#EBEDEE', boxstyle='round,pad=0.3'))
axes3d.set_aspect("equal")
Почему это важно
Надежное размещение подписей — не косметика. От него напрямую зависит понимание данных и скорость, с которой читатель считывает числовые значения. С небольшим и единообразным смещением по z и согласованным выравниванием подписи оказываются точно под столбцами — предсказуемо и разборчиво, что критично, когда нужно считывать цифры с первого взгляда.
Прошу прощения — вы наверняка не просили этот совет, — но лучше отказаться от 3D‑столбцов. Хорошо известно, что их невозможно точно читать. Глядя на ваш график, данных не так много — почти наверняка таблица подошла бы лучше.
Выводы
Если подписи на 3D‑столбцах выглядят неровно, не пытайтесь справиться только с zdir. Привяжите текст к основанию столбца и добавьте небольшой отрицательный сдвиг по оси z, одновременно использовав центральное горизонтальное выравнивание и верхнюю вертикальную привязку. Изменение минимально, но оно стабилизирует связь между подписями и столбцами и делает диаграмму заметно более читаемой.