3D Bar Chart for Categorical Comparison — lets-plot

A three-dimensional bar chart where bars rise from a 2D categorical grid, with height encoding the measured value. Two categorical axes define the grid position on the base plane while the vertical axis shows magnitude. This visualization extends the bar chart family into 3D space, making it effective for comparing values across two categorical dimensions simultaneously.

3D Bar Chart for Categorical Comparison rendered with lets-plot

Renders

Python source (lets-plot)

""" anyplot.ai
bar-3d-categorical: 3D Bar Chart for Categorical Comparison
Library: letsplot 4.9.0 | Python 3.13.13
Quality: 83/100 | Created: 2026-05-15
"""

import os

import numpy as np
import pandas as pd
from lets_plot import *


LetsPlot.setup_html()

THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
GRID_COLOR = "#C8C7C0" if THEME == "light" else "#3A3A35"

IMPRINT = ["#009E73", "#C475FD", "#4467A3"]


def shade(col, f):
    """Adjust hex color brightness by factor f."""
    r = min(255, max(0, round(int(col[1:3], 16) * f)))
    g = min(255, max(0, round(int(col[3:5], 16) * f)))
    b = min(255, max(0, round(int(col[5:7], 16) * f)))
    return f"#{r:02X}{g:02X}{b:02X}"


np.random.seed(42)
X_CATS = ["18–34", "35–49", "50–64", "65+"]
Y_CATS = ["High School", "Bachelor's", "Graduate"]  # index 0 = furthest back

BASE = {"High School": [6.1, 5.7, 5.4, 5.8], "Bachelor's": [7.0, 7.3, 6.8, 7.1], "Graduate": [7.7, 8.0, 7.5, 7.8]}

NX = len(X_CATS)
NY = len(Y_CATS)

vals = {
    (ai, ei): round(BASE[edu][ai] + np.random.uniform(-0.15, 0.15), 1)
    for ei, edu in enumerate(Y_CATS)
    for ai in range(NX)
}

# Cabinet projection: x3=age-group axis, y3=depth axis, z3=height
BW = 0.78  # bar width (x3 units; gap = 1 - BW)
BD = 0.65  # bar depth (y3 units)
DX = 0.52  # x2 shift per unit of y3 depth
DZ = 0.32  # z2 (upward) shift per unit of y3 depth


def prj(x3, y3, z3):
    return (x3 + y3 * DX, z3 + y3 * DZ)


# ── Base-plane grid ──────────────────────────────────────────────────
seg_rows = []


def seg(x1, y1, x2, y2):
    seg_rows.append({"x": x1, "y": y1, "xend": x2, "yend": y2})


# Lines along the depth direction (x edges of the grid)
for xi in range(NX + 1):
    p0 = prj(xi, 0, 0)
    p1 = prj(xi, NY - 1 + BD, 0)
    seg(p0[0], p0[1], p1[0], p1[1])

# Lines along the x direction (depth edges of the grid)
for yi_f in [0.0, 1.0, 2.0, NY - 1 + BD]:
    p0 = prj(0, yi_f, 0)
    p1 = prj(NX, yi_f, 0)
    seg(p0[0], p0[1], p1[0], p1[1])

df_seg = pd.DataFrame(seg_rows)

# ── Z-axis (left-front vertical) ────────────────────────────────────
z_segs = []
ztick_rows = []
Z_TICKS = [0, 2, 4, 6, 8, 10]

ax0 = prj(0, 0, 0)
ax1 = prj(0, 0, 10)
z_segs.append({"x": ax0[0], "y": ax0[1], "xend": ax1[0], "yend": ax1[1]})

for zt in Z_TICKS:
    pt = prj(0, 0, zt)
    z_segs.append({"x": pt[0], "y": pt[1], "xend": pt[0] - 0.18, "yend": pt[1]})
    ztick_rows.append({"x": pt[0] - 0.25, "y": pt[1], "label": str(zt)})

df_zsegs = pd.DataFrame(z_segs)
df_zticks = pd.DataFrame(ztick_rows)

# ── Category axis labels ─────────────────────────────────────────────
x_lbl = []
for ai, age in enumerate(X_CATS):
    pt = prj(ai + BW / 2, 0, 0)
    x_lbl.append({"x": pt[0], "y": pt[1] - 0.55, "label": age})
df_xlbl = pd.DataFrame(x_lbl)

# ── Bar polygons (painter's order: back → front) ─────────────────────
poly_rows = []
gc = [0]


def face(pts, fill_col):
    gid = str(gc[0])
    gc[0] += 1
    for px, py in pts:
        poly_rows.append({"x": px, "y": py, "g": gid, "fill": fill_col})


for ei in range(NY - 1, -1, -1):  # draw back→front; ei=0 (High School) drawn last = in front
    c0 = IMPRINT[ei]
    c_front = c0
    c_right = shade(c0, 0.60)
    c_top = shade(c0, 1.32)

    for ai in range(NX):
        h = vals[(ai, ei)]
        x0, x1 = ai, ai + BW
        y0, y1 = ei, ei + BD

        C = {
            k: prj(*v)
            for k, v in {
                "bfl": (x0, y0, 0),
                "bfr": (x1, y0, 0),
                "bbl": (x0, y1, 0),
                "bbr": (x1, y1, 0),
                "tfl": (x0, y0, h),
                "tfr": (x1, y0, h),
                "tbl": (x0, y1, h),
                "tbr": (x1, y1, h),
            }.items()
        }

        face([C["bfl"], C["bfr"], C["tfr"], C["tfl"]], c_front)  # front
        face([C["bfr"], C["bbr"], C["tbr"], C["tfr"]], c_right)  # right
        face([C["tfl"], C["tfr"], C["tbr"], C["tbl"]], c_top)  # top

df_poly = pd.DataFrame(poly_rows)

# ── Value labels (centre-top of each bar) ───────────────────────────
val_rows = []
for ei in range(NY):
    for ai in range(NX):
        h = vals[(ai, ei)]
        pt = prj(ai + BW / 2, ei + BD / 2, h)
        val_rows.append({"x": pt[0], "y": pt[1] + 0.28, "label": f"{h:.1f}"})
df_vals = pd.DataFrame(val_rows)

# ── Manual legend inside chart (upper-left area, above bars) ─────────
LEG_X = 0.15
LEG_Y0 = 10.8
LEG_DY = 0.65
leg_title = [{"x": LEG_X, "y": LEG_Y0 + 0.4, "label": "Education Level"}]
leg_rect = []
leg_text = []
for i, edu in enumerate(Y_CATS):
    lx, ly = LEG_X, LEG_Y0 - i * LEG_DY
    leg_rect.append({"xmin": lx, "xmax": lx + 0.32, "ymin": ly - 0.20, "ymax": ly + 0.20, "fill": IMPRINT[i]})
    leg_text.append({"x": lx + 0.42, "y": ly, "label": edu})

df_ltitle = pd.DataFrame(leg_title)
df_lrect = pd.DataFrame(leg_rect)
df_ltxt = pd.DataFrame(leg_text)

# unique fill → itself (scale_fill_manual with identity mapping)
fill_vals = {c: c for c in df_poly["fill"].unique()}

# ── Assemble plot ─────────────────────────────────────────────────────
plot = (
    ggplot()
    + geom_segment(aes(x="x", y="y", xend="xend", yend="yend"), data=df_seg, color=GRID_COLOR, size=0.8)
    + geom_segment(aes(x="x", y="y", xend="xend", yend="yend"), data=df_zsegs, color=INK_SOFT, size=0.7)
    + geom_polygon(aes(x="x", y="y", group="g", fill="fill"), data=df_poly, color=PAGE_BG, size=0.3)
    + scale_fill_manual(values=fill_vals, guide="none")
    + geom_text(aes(x="x", y="y", label="label"), data=df_zticks, size=11, color=INK_SOFT, hjust=1)
    + geom_text(aes(x="x", y="y", label="label"), data=df_xlbl, size=12, color=INK_SOFT, vjust=1)
    + geom_text(aes(x="x", y="y", label="label"), data=df_vals, size=10, color=INK)
    + geom_text(aes(x="x", y="y", label="label"), data=df_ltitle, size=14, color=INK, hjust=0, fontface="bold")
    + geom_rect(
        aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", fill="fill"), data=df_lrect, color=INK_SOFT, size=0.3
    )
    + geom_text(aes(x="x", y="y", label="label"), data=df_ltxt, size=13, color=INK_SOFT, hjust=0)
    + labs(title="bar-3d-categorical · letsplot · anyplot.ai")
    + theme_void()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        plot_title=element_text(color=INK, size=24, hjust=0.5),
        plot_margin=[30, 40, 30, 40],
    )
    + ggsize(1600, 900)
)

ggsave(plot, f"plot-{THEME}.png", path=".", scale=3)
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of 3D Bar Chart for Categorical Comparison on anyplot.ai.

Other implementations