A bullet chart displays a single measure against qualitative ranges and a target marker, designed by Stephen Few as a space-efficient alternative to gauge charts. The linear format shows actual performance as a bar, a target as a vertical marker, and background bands representing qualitative ranges (poor/satisfactory/good). Its compact design allows multiple bullet charts to fit on a single dashboard for easy comparison across metrics.

""" anyplot.ai
bullet-basic: Basic Bullet Chart
Library: letsplot 4.10.1 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-29
"""
# ruff: noqa: F405
import os
import shutil
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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
# Imprint palette — semantic exception: green = above/success, red = below/failure
ABOVE_COLOR = "#009E73" # brand green
BELOW_COLOR = "#AE3030" # matte red
# Grayscale band shades — adjusted per theme so bands are visible on both surfaces
if THEME == "light":
BAND_GOOD = "#D0D0D0"
BAND_SAT = "#989898"
BAND_POOR = "#585858"
else:
BAND_GOOD = "#3C3C38"
BAND_SAT = "#565650"
BAND_POOR = "#707068"
# Data — Q4 2024 KPI dashboard with varied performance levels
metrics = ["Revenue ($K)", "Profit Margin (%)", "Satisfaction", "New Customers"]
actual = [275, 38, 3.8, 42]
target = [300, 42, 4.5, 40]
poor = [100, 20, 2.5, 15]
satisfactory = [200, 35, 3.5, 30]
good = [350, 50, 5.0, 50]
n = len(metrics)
# Normalize to percentage of maximum range
actual_pct = [actual[i] / good[i] * 100 for i in range(n)]
target_pct = [target[i] / good[i] * 100 for i in range(n)]
poor_pct = [poor[i] / good[i] * 100 for i in range(n)]
sat_pct = [satisfactory[i] / good[i] * 100 for i in range(n)]
status = ["Above Target" if actual[i] >= target[i] else "Below Target" for i in range(n)]
# Y positions — reversed for top-to-bottom reading
y_spacing = 0.90
y_pos = [i * y_spacing for i in range(n - 1, -1, -1)]
bar_h = 0.38
narrow_h = 0.17
marker_h = 0.33
# Qualitative range bands (grayscale, Stephen Few convention)
range_rows = []
for i in range(n):
y = y_pos[i]
range_rows.append({"xmin": 0, "xmax": 100, "ymin": y - bar_h, "ymax": y + bar_h, "band": "Good"})
range_rows.append({"xmin": 0, "xmax": sat_pct[i], "ymin": y - bar_h, "ymax": y + bar_h, "band": "Satisfactory"})
range_rows.append({"xmin": 0, "xmax": poor_pct[i], "ymin": y - bar_h, "ymax": y + bar_h, "band": "Poor"})
df_ranges = pd.DataFrame(range_rows)
# Actual value bars with interactive tooltips
actual_rows = []
for i in range(n):
y = y_pos[i]
actual_rows.append(
{
"xmin": 0,
"xmax": actual_pct[i],
"ymin": y - narrow_h,
"ymax": y + narrow_h,
"status": status[i],
"metric": metrics[i],
"actual_val": f"{actual[i]:g}",
"target_val": f"{target[i]:g}",
"achievement": f"{actual[i] / target[i] * 100:.0f}%",
}
)
df_actual = pd.DataFrame(actual_rows)
# Target markers
target_rows = []
for i in range(n):
y = y_pos[i]
target_rows.append({"x": target_pct[i], "y": y - marker_h, "xend": target_pct[i], "yend": y + marker_h})
df_target = pd.DataFrame(target_rows)
# Value annotations (actual units, beside each bar)
# When the bar end is within 8 pp of the target marker, place annotation after the
# marker instead of between bar-end and marker to avoid overlap.
annot_labels = ["$275K", "38%", "3.8", "42"]
annot_rows = []
for i in range(n):
crowd_target = actual_pct[i] < target_pct[i] and (target_pct[i] - actual_pct[i]) < 8
if crowd_target:
annot_rows.append(
{"x": target_pct[i] + 2, "y": float(y_pos[i]), "label": annot_labels[i], "status": status[i], "hjust": 0.0}
)
else:
annot_rows.append(
{"x": actual_pct[i] + 4, "y": float(y_pos[i]), "label": annot_labels[i], "status": status[i], "hjust": 0.0}
)
df_annot = pd.DataFrame(annot_rows)
# Band legend note — dark-mode bands are lighter for Poor (more contrast on dark bg),
# so the descriptor text must flip to avoid being factually wrong in dark render
if THEME == "light":
band_note_text = "Bands: Dark = Poor · Medium = Satisfactory · Light = Good"
else:
band_note_text = "Bands: Light = Poor · Medium = Satisfactory · Dark = Good"
df_band_note = pd.DataFrame([{"x": 0, "y": -0.58, "label": band_note_text}])
# Build layered bullet chart
plot = (
ggplot()
# Qualitative range bands
+ geom_rect(data=df_ranges, mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", fill="band"), size=0)
# Actual value bars
+ geom_rect(
data=df_actual,
mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", fill="status"),
size=0,
tooltips=(
layer_tooltips()
.line("@{metric}")
.line("Actual|@{actual_val}")
.line("Target|@{target_val}")
.line("Achievement|@{achievement}")
),
)
# Target markers — thin vertical lines in INK color
+ geom_segment(data=df_target, mapping=aes(x="x", y="y", xend="xend", yend="yend"), size=2.5, color=INK)
# Value annotations beside each bar
+ geom_text(
data=df_annot,
mapping=aes(x="x", y="y", label="label", color="status"),
size=5,
hjust=0,
fontface="bold",
show_legend=False,
)
# Band legend explanation
+ geom_text(data=df_band_note, mapping=aes(x="x", y="y", label="label"), size=4, hjust=0, color=INK_MUTED)
# Fill scale — bands (grey) + status (Imprint palette)
+ scale_fill_manual(
values={
"Good": BAND_GOOD,
"Satisfactory": BAND_SAT,
"Poor": BAND_POOR,
"Above Target": ABOVE_COLOR,
"Below Target": BELOW_COLOR,
},
labels={"Above Target": "↑ Above Target", "Below Target": "↓ Below Target"},
breaks=["Above Target", "Below Target"],
name="Performance",
)
+ scale_color_manual(values={"Above Target": ABOVE_COLOR, "Below Target": BELOW_COLOR}, guide="none")
# Axes
+ scale_x_continuous(name="Performance (%)", limits=[0, 108], expand=[0, 1])
+ scale_y_continuous(breaks=y_pos, labels=metrics, limits=[-0.78, 3.25], expand=[0, 0])
+ labs(
title="bullet-basic · python · letsplot · anyplot.ai",
subtitle="Q4 2024 Dashboard — Actual vs. Target Performance",
y="",
)
+ theme_minimal()
+ theme(
plot_title=element_text(size=16, face="bold", color=INK),
plot_subtitle=element_text(size=11, color=INK_SOFT),
axis_title_x=element_text(size=12, color=INK),
axis_title_y=element_blank(),
axis_text_x=element_text(size=10, color=INK_SOFT),
axis_text_y=element_text(size=10, face="bold", color=INK_SOFT),
legend_position="bottom",
legend_direction="horizontal",
legend_title=element_text(size=10, face="bold", color=INK),
legend_text=element_text(size=10, color=INK_SOFT),
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
panel_grid_major_y=element_blank(),
panel_grid_minor=element_blank(),
panel_grid_major_x=element_line(size=0.3, color=INK_SOFT),
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG),
)
+ ggsize(800, 450)
)
# Save — theme-suffixed, scale=4 yields 3200×1800 px
ggsave(plot, f"plot-{THEME}.png", scale=4)
ggsave(plot, f"plot-{THEME}.html")
# Move from lets-plot-images subfolder if ggsave placed files there
for fname in [f"plot-{THEME}.png", f"plot-{THEME}.html"]:
src = os.path.join("lets-plot-images", fname)
if os.path.exists(src):
shutil.move(src, fname)
if os.path.exists("lets-plot-images"):
shutil.rmtree("lets-plot-images")
Part of Basic Bullet Chart on anyplot.ai.