Basic Treemap — lets-plot

A treemap displaying hierarchical data as nested rectangles, where each rectangle's area is proportional to its value. This visualization excels at showing part-to-whole relationships in hierarchical structures, making it easy to spot large and small items at a glance. Treemaps efficiently use screen space to display large amounts of hierarchical data in a compact form.

Basic Treemap rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
treemap-basic: Basic Treemap
Library: letsplot 4.11.0 | Python 3.13.14
Quality: 88/100 | Updated: 2026-08-04
"""

import os

import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_rect,
    element_text,
    geom_rect,
    geom_text,
    ggplot,
    ggsize,
    labs,
    scale_alpha_identity,
    scale_color_identity,
    scale_fill_manual,
    theme,
    theme_void,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Theme tokens
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"

# Okabe-Ito palette for consistent color mapping across categories
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477"]

# Data - Budget allocation with two-level hierarchy
# Departments (main) and projects/teams (sub) for realistic budget breakdown
data = {
    "category": [
        "Engineering",
        "Engineering",
        "Engineering",
        "Marketing",
        "Marketing",
        "Sales",
        "Sales",
        "Operations",
        "HR",
        "Finance",
    ],
    "subcategory": [
        "Backend",
        "Frontend",
        "DevOps",
        "Digital",
        "Events",
        "Enterprise",
        "SMB",
        "Infrastructure",
        "Recruiting",
        "Planning",
    ],
    "value": [15, 12, 5, 14, 8, 12, 6, 12, 7, 5],
}

df_data = pd.DataFrame(data)
df_data = df_data.sort_values("value", ascending=False).reset_index(drop=True)


def squarify(values, x, y, width, height):
    """Compute treemap rectangles using squarify algorithm.

    Tracks a running remaining-total and remaining width/height (rather than
    the fixed global values) so consumed area always matches the actual
    remaining container, guaranteeing the tiling fully fills the bounding box.
    """
    if len(values) == 0:
        return []

    remaining_total = sum(values)
    if remaining_total == 0:
        return []

    rects = []
    remaining_values = list(values)
    remaining_x, remaining_y = x, y
    remaining_w, remaining_h = width, height

    while remaining_values:
        if remaining_w >= remaining_h:
            row_values = []
            row_sum = 0
            best_ratio = float("inf")

            for v in remaining_values:
                test_values = row_values + [v]
                test_sum = row_sum + v
                row_width = (test_sum / remaining_total) * remaining_w if remaining_total > 0 else 0

                if row_width > 0:
                    worst_ratio = 0
                    for rv in test_values:
                        rect_height = (rv / test_sum) * remaining_h if test_sum > 0 else 0
                        ratio = (
                            max(row_width / rect_height, rect_height / row_width) if rect_height > 0 else float("inf")
                        )
                        worst_ratio = max(worst_ratio, ratio)

                    if worst_ratio <= best_ratio:
                        best_ratio = worst_ratio
                        row_values = test_values
                        row_sum = test_sum
                    else:
                        break
                else:
                    row_values = test_values
                    row_sum = test_sum

            row_width = (row_sum / remaining_total) * remaining_w if remaining_total > 0 else 0
            current_y = remaining_y
            for rv in row_values:
                rect_height = (rv / row_sum) * remaining_h if row_sum > 0 else 0
                rects.append((remaining_x, current_y, row_width, rect_height))
                current_y += rect_height

            remaining_x += row_width
            remaining_w -= row_width
            remaining_total -= row_sum
            remaining_values = remaining_values[len(row_values) :]
        else:
            col_values = []
            col_sum = 0
            best_ratio = float("inf")

            for v in remaining_values:
                test_values = col_values + [v]
                test_sum = col_sum + v
                col_height = (test_sum / remaining_total) * remaining_h if remaining_total > 0 else 0

                if col_height > 0:
                    worst_ratio = 0
                    for cv in test_values:
                        rect_width = (cv / test_sum) * remaining_w if test_sum > 0 else 0
                        ratio = (
                            max(col_height / rect_width, rect_width / col_height) if rect_width > 0 else float("inf")
                        )
                        worst_ratio = max(worst_ratio, ratio)

                    if worst_ratio <= best_ratio:
                        best_ratio = worst_ratio
                        col_values = test_values
                        col_sum = test_sum
                    else:
                        break
                else:
                    col_values = test_values
                    col_sum = test_sum

            col_height = (col_sum / remaining_total) * remaining_h if remaining_total > 0 else 0
            current_x = remaining_x
            for cv in col_values:
                rect_width = (cv / col_sum) * remaining_w if col_sum > 0 else 0
                rects.append((current_x, remaining_y, rect_width, col_height))
                current_x += rect_width

            remaining_y += col_height
            remaining_h -= col_height
            remaining_total -= col_sum
            remaining_values = remaining_values[len(col_values) :]

    return rects


# Compute treemap layout
rects = squarify(df_data["value"].tolist(), 0, 0, 100, 100)

# Build rectangle dataframe
rect_df = pd.DataFrame(
    {
        "xmin": [r[0] for r in rects],
        "ymin": [r[1] for r in rects],
        "xmax": [r[0] + r[2] for r in rects],
        "ymax": [r[1] + r[3] for r in rects],
        "category": df_data["category"].tolist(),
        "subcategory": df_data["subcategory"].tolist(),
        "value": df_data["value"].tolist(),
    }
)

# Calculate label positions
rect_df["label_x"] = (rect_df["xmin"] + rect_df["xmax"]) / 2
rect_df["label_y"] = (rect_df["ymin"] + rect_df["ymax"]) / 2
rect_df["width"] = rect_df["xmax"] - rect_df["xmin"]
rect_df["height"] = rect_df["ymax"] - rect_df["ymin"]

# Shading intensity by nesting depth: within each department, the largest
# cost center is fully opaque and successive ones step down in alpha, giving
# a visual cue for the subcategory hierarchy beyond color alone.
rect_df["subcat_rank"] = rect_df.groupby("category")["value"].rank(ascending=False, method="first") - 1
rect_df["shade_alpha"] = (0.95 - 0.15 * rect_df["subcat_rank"]).clip(lower=0.55)

# Create adaptive labels for improved readability
total_value = df_data["value"].sum()


def make_label(row):
    w, h = row["width"], row["height"]
    pct = row["value"] / total_value * 100
    # Large rectangles: show both category and subcategory with percentage
    if w > 20 and h > 12:
        return f"{row['category']}\n{row['subcategory']}\n{pct:.0f}%"
    # Medium rectangles: subcategory and percentage
    elif w > 12 and h > 8:
        return f"{row['subcategory']}\n{pct:.0f}%"
    # Small rectangles: just subcategory
    elif w > 8 and h > 6:
        return f"{row['subcategory']}"
    # Very small: no label (visible in legend)
    return ""


rect_df["label"] = rect_df.apply(make_label, axis=1)

# Map categories to Okabe-Ito colors
unique_categories = df_data["category"].unique().tolist()
category_colors = {cat: IMPRINT[i % len(IMPRINT)] for i, cat in enumerate(unique_categories)}
color_values = [category_colors[cat] for cat in unique_categories]

# Per-swatch label color: pick whichever of near-black/near-white ink gives
# the higher WCAG contrast against that category's fill, so labels stay
# legible on both light swatches (e.g. lavender) and dark ones (e.g. red).
DARK_INK = "#1A1A17"
LIGHT_INK = "#F0EFE8"


def relative_luminance(hex_color):
    r, g, b = (int(hex_color.lstrip("#")[i : i + 2], 16) / 255 for i in (0, 2, 4))

    def channel(c):
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4

    r, g, b = channel(r), channel(g), channel(b)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contrast_ratio(lum_a, lum_b):
    lighter, darker = max(lum_a, lum_b), min(lum_a, lum_b)
    return (lighter + 0.05) / (darker + 0.05)


def best_label_color(bg_hex):
    bg_lum = relative_luminance(bg_hex)
    dark_contrast = contrast_ratio(bg_lum, relative_luminance(DARK_INK))
    light_contrast = contrast_ratio(bg_lum, relative_luminance(LIGHT_INK))
    return DARK_INK if dark_contrast >= light_contrast else LIGHT_INK


label_colors = {cat: best_label_color(color) for cat, color in category_colors.items()}
rect_df["label_color"] = rect_df["category"].map(label_colors)
TEXT_SIZE = 7

# Create the plot
plot = (
    ggplot(rect_df)
    + geom_rect(
        aes(xmin="xmin", ymin="ymin", xmax="xmax", ymax="ymax", fill="category", alpha="shade_alpha"),
        color=INK_SOFT,
        size=0.7,
    )
    + geom_text(aes(x="label_x", y="label_y", label="label", color="label_color"), size=TEXT_SIZE, fontface="bold")
    + scale_fill_manual(values=color_values)
    + scale_color_identity()
    + scale_alpha_identity()
    + labs(title="Budget Breakdown · treemap-basic · python · letsplot · anyplot.ai", fill="Department")
    + theme_void()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        plot_title=element_text(size=16, color=INK, hjust=0.5),
        legend_title=element_text(size=12, color=INK),
        legend_text=element_text(size=10, color=INK_SOFT),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_position="right",
        axis_title=element_blank(),
        axis_text=element_blank(),
    )
    + ggsize(800, 450)
)

# Save outputs with theme suffix
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of Basic Treemap on anyplot.ai.

Other implementations