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, одновременно использовав центральное горизонтальное выравнивание и верхнюю вертикальную привязку. Изменение минимально, но оно стабилизирует связь между подписями и столбцами и делает диаграмму заметно более читаемой.