A radial chart that maps innovations, technologies, or trends onto concentric rings representing time horizons and angular sectors representing thematic categories. Inner rings represent near-term items (e.g., "Now", "Next 6 months") while outer rings represent longer-term or emerging trends (e.g., "2-5 years", "Future"). Each item is placed as a labeled point within its sector and ring, with distinct markers or colors encoding categories. Inspired by ThoughtWorks Technology Radar and similar strategic planning visualizations.

""" anyplot.ai
radar-innovation-timeline: Innovation Radar with Time-Horizon Rings
Library: seaborn 0.13.2 | Python 3.13.13
Quality: 85/100 | Updated: 2026-05-29
"""
import os
import matplotlib.lines as mlines
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
# 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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
# Imprint palette — first 4 positions for sector encoding
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
sns.set_theme(
style="ticks",
rc={
"figure.facecolor": PAGE_BG,
"axes.facecolor": PAGE_BG,
"axes.edgecolor": INK_SOFT,
"axes.labelcolor": INK,
"text.color": INK,
"xtick.color": INK_SOFT,
"ytick.color": INK_SOFT,
"grid.color": INK,
"grid.alpha": 0.15,
"legend.facecolor": ELEVATED_BG,
"legend.edgecolor": INK_SOFT,
},
)
np.random.seed(42)
# Configuration
sectors = ["AI & ML", "Cloud & Infra", "Sustainability", "Biotech"]
rings = ["Adopt", "Trial", "Assess", "Hold"]
ring_radii = {"Adopt": 1.0, "Trial": 2.0, "Assess": 3.0, "Hold": 4.0}
ring_importance = {"Adopt": 4, "Trial": 3, "Assess": 2, "Hold": 1}
sector_colors = IMPRINT_PALETTE[:4]
sector_palette = dict(zip(sectors, sector_colors, strict=True))
sector_markers = {"AI & ML": "o", "Cloud & Infra": "s", "Sustainability": "D", "Biotech": "^"}
total_angle_deg = 270
sector_width_deg = total_angle_deg / len(sectors)
start_angle_deg = 135
# Ring fills — Imprint colors at low alpha, visible on both themes
ring_fill_colors = ["#009E73", "#4467A3", "#BD8233", "#AE3030"]
ring_boundaries = [0.5, 1.5, 2.5, 3.5, 4.5]
# Boundary accent gradient via seaborn blend
ring_accent = sns.blend_palette(["#009E73", "#BD8233", "#AE3030"], n_colors=5)
# Innovation data (27 items across 4 rings × 4 sectors)
innovations = [
("LLM Agents", "Adopt", "AI & ML"),
("RAG Pipelines", "Adopt", "AI & ML"),
("Multimodal Models", "Trial", "AI & ML"),
("Federated Learning", "Assess", "AI & ML"),
("Neuromorphic Chips", "Hold", "AI & ML"),
("AI Code Review", "Trial", "AI & ML"),
("Synthetic Data Gen", "Assess", "AI & ML"),
("Platform Eng.", "Adopt", "Cloud & Infra"),
("eBPF Observability", "Trial", "Cloud & Infra"),
("Wasm Edge", "Assess", "Cloud & Infra"),
("Confid. Compute", "Trial", "Cloud & Infra"),
("Serverless GPUs", "Assess", "Cloud & Infra"),
("Quantum Network", "Hold", "Cloud & Infra"),
("RISC-V Servers", "Hold", "Cloud & Infra"),
("Carbon Accounting", "Adopt", "Sustainability"),
("Green Software", "Trial", "Sustainability"),
("Digital Twins", "Assess", "Sustainability"),
("Circular Supply", "Trial", "Sustainability"),
("Ocean Carbon Cap.", "Hold", "Sustainability"),
("Energy Harvest IoT", "Assess", "Sustainability"),
("mRNA Therapeutics", "Adopt", "Biotech"),
("CRISPR Diagnostics", "Trial", "Biotech"),
("Organ-on-Chip", "Assess", "Biotech"),
("Biocomputing", "Hold", "Biotech"),
("Precision Nutrition", "Trial", "Biotech"),
("Longevity Biomarkers", "Assess", "Biotech"),
("Phage Therapy", "Hold", "Biotech"),
]
# Build DataFrame with polar positions
records = []
for name, ring, sector in innovations:
sector_idx = sectors.index(sector)
same_group = [(n, r, s) for n, r, s in innovations if s == sector and r == ring]
item_idx = same_group.index((name, ring, sector))
n_in_group = len(same_group)
sector_start = np.deg2rad(start_angle_deg - sector_idx * sector_width_deg)
sector_end = np.deg2rad(start_angle_deg - (sector_idx + 1) * sector_width_deg)
margin = 0.10 * (sector_start - sector_end)
usable_start = sector_start - margin
usable_end = sector_end + margin
if n_in_group == 1:
angle = (usable_start + usable_end) / 2
elif n_in_group == 2:
mid = (usable_start + usable_end) / 2
half = 0.80 * (usable_start - usable_end) / 2
angle = mid + half if item_idx == 0 else mid - half
else:
angle = usable_start + (usable_end - usable_start) * item_idx / (n_in_group - 1)
radial_jitter = (
0.24
if (n_in_group == 2 and item_idx == 0)
else (-0.24 if (n_in_group == 2 and item_idx == 1) else np.random.uniform(-0.22, 0.22))
)
radius = ring_radii[ring] + radial_jitter
records.append(
{
"name": name,
"ring": ring,
"sector": sector,
"angle": angle,
"radius": radius,
"importance": ring_importance[ring],
}
)
df = pd.DataFrame(records)
# Plot — square canvas for radar (2400×2400 px); leave bottom 20% for legend
fig = plt.figure(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)
ax = fig.add_subplot(111, projection="polar")
ax.set_facecolor(PAGE_BG)
ax.set_theta_zero_location("N")
ax.set_theta_direction(-1)
fig.subplots_adjust(left=0.05, right=0.95, top=0.91, bottom=0.20)
# Ring background fills — Imprint colors at very low alpha, theme-neutral
theta_fill = np.linspace(0, 2 * np.pi, 200)
for i in range(len(rings)):
ax.fill_between(
theta_fill, ring_boundaries[i], ring_boundaries[i + 1], color=ring_fill_colors[i], alpha=0.18, zorder=0
)
# Ring boundary lines with Imprint-derived gradient
for i, rb in enumerate(ring_boundaries):
lw = 1.2 if i == 1 else 0.7
ax.plot(
theta_fill,
np.full_like(theta_fill, rb),
color=sns.desaturate(ring_accent[i], 0.5),
linewidth=lw,
alpha=0.5,
zorder=1,
)
# Sector divider lines
for i in range(len(sectors) + 1):
angle = np.deg2rad(start_angle_deg - i * sector_width_deg)
ax.plot([angle, angle], [0.5, 4.5], color=INK_SOFT, linewidth=0.8, alpha=0.4, zorder=1)
# Plot innovations — seaborn scatterplot with color + shape + size encoding
size_map = {1: 150, 2: 250, 3: 340, 4: 440}
sns.scatterplot(
data=df,
x="angle",
y="radius",
hue="sector",
style="sector",
size="importance",
sizes=size_map,
markers=sector_markers,
palette=sector_palette,
edgecolor=PAGE_BG,
linewidth=1.2,
alpha=0.9,
legend=False,
ax=ax,
zorder=5,
)
# Subtle halo per sector using seaborn scatterplot
for sector_name in sectors:
sector_df = df[df["sector"] == sector_name]
sns.scatterplot(
data=sector_df,
x="angle",
y="radius",
color=sector_palette[sector_name],
s=500,
alpha=0.07,
legend=False,
ax=ax,
zorder=3,
)
# Axes setup — clean polar frame
ax.set_ylim(0, 6.0)
ax.set_yticks([])
ax.set_xticks([])
ax.set_xlabel("")
ax.set_ylabel("")
ax.grid(False)
ax.spines["polar"].set_visible(False)
fig.canvas.draw()
# Innovation labels with improved collision detection and canvas boundary awareness
placed_boxes = []
DPI = fig.dpi
PT = DPI / 72.0
FONT_SIZE = 10
CHAR_W = FONT_SIZE * 0.62 * PT
CHAR_H = FONT_SIZE * 1.3 * PT
BOX_PAD = 6 * PT
fig_width_px = fig.get_figwidth() * DPI
fig_height_px = fig.get_figheight() * DPI
MARGIN_PX = 8 * PT
df_sorted = df.sort_values("radius", ascending=False).reset_index(drop=True)
for _, row in df_sorted.iterrows():
angle, radius, name = row["angle"], row["radius"], row["name"]
angle_deg = np.rad2deg(angle) % 360
px, py = ax.transData.transform((angle, radius))
if 30 < angle_deg < 150:
ha, base_x = "left", 10
elif 210 < angle_deg < 330:
ha, base_x = "right", -10
else:
ha, base_x = "center", 0
best_pos, best_score = (12, base_x), float("inf")
for y in [12, -12, 20, -20, 30, -30, 40, -40, 50, -50]:
for dx_adj in [0, 10, -10, 20, -20, 30, -30]:
x_off = base_x + dx_adj
va_c = "bottom" if y > 0 else "top"
cx = px + x_off * PT
cy = py + y * PT
w = len(name) * CHAR_W + BOX_PAD * 2
h = CHAR_H + BOX_PAD * 2
x0 = cx if ha == "left" else (cx - w if ha == "right" else cx - w / 2)
y0 = cy if va_c == "bottom" else cy - h
# Canvas boundary penalty — strongly prefer in-bounds placements
boundary_penalty = 0
if x0 < MARGIN_PX or x0 + w > fig_width_px - MARGIN_PX:
boundary_penalty += 50
if y0 < MARGIN_PX or y0 + h > fig_height_px - MARGIN_PX:
boundary_penalty += 50
m = 3 * PT
overlap_count = sum(
1
for bx in placed_boxes
if not (x0 + w + m < bx[0] or bx[0] + bx[2] + m < x0 or y0 + h + m < bx[1] or bx[1] + bx[3] + m < y0)
)
score = boundary_penalty + overlap_count
if score < best_score:
best_score = score
best_pos = (y, x_off)
if score == 0:
break
if best_score == 0:
break
y_off, x_off = best_pos
va = "bottom" if y_off > 0 else "top"
ax.annotate(
name,
xy=(angle, radius),
xytext=(x_off, y_off),
textcoords="offset points",
fontsize=FONT_SIZE,
color=INK,
fontweight="medium",
ha=ha,
va=va,
bbox={"boxstyle": "round,pad=0.18", "facecolor": ELEVATED_BG, "edgecolor": "none", "alpha": 0.88},
arrowprops={"arrowstyle": "-", "color": INK_SOFT, "linewidth": 0.5},
zorder=6,
)
cx_f = px + x_off * PT
cy_f = py + y_off * PT
w_f = len(name) * CHAR_W + BOX_PAD * 2
h_f = CHAR_H + BOX_PAD * 2
x0_f = cx_f if ha == "left" else (cx_f - w_f if ha == "right" else cx_f - w_f / 2)
y0_f = cy_f if va == "bottom" else cy_f - h_f
placed_boxes.append((x0_f, y0_f, w_f, h_f))
# Sector header labels
for i, sector_name in enumerate(sectors):
mid_angle = np.deg2rad(start_angle_deg - (i + 0.5) * sector_width_deg)
ax.text(
mid_angle,
5.4,
sector_name,
ha="center",
va="center",
fontsize=16,
fontweight="bold",
color=sector_palette[sector_name],
zorder=7,
)
# Ring labels along the gap edge
label_angle = np.deg2rad(start_angle_deg - total_angle_deg - 8)
for ring_name, ring_r in zip(rings, [1.0, 2.0, 3.0, 4.0], strict=True):
ax.text(
label_angle,
ring_r,
ring_name,
ha="center",
va="center",
fontsize=12,
fontweight="bold",
color=INK_SOFT,
bbox={"boxstyle": "round,pad=0.2", "facecolor": ELEVATED_BG, "edgecolor": "none", "alpha": 0.88},
zorder=7,
)
# Directional storytelling cues (Imprint-derived colors)
dir_angle = np.deg2rad(start_angle_deg - total_angle_deg - 22)
ax.text(
dir_angle,
0.7,
"◂ Ready",
fontsize=9,
color=sns.desaturate("#009E73", 0.55),
fontweight="bold",
ha="center",
va="center",
zorder=7,
)
ax.text(
dir_angle,
4.7,
"Emerging ▸",
fontsize=9,
color=sns.desaturate("#AE3030", 0.55),
fontweight="bold",
ha="center",
va="center",
zorder=7,
)
# Title — figure-centered via suptitle so it doesn't clip at axes boundaries
title = "radar-innovation-timeline · python · seaborn · anyplot.ai"
title_fontsize = max(8, round(12 * 67 / len(title)))
fig.suptitle(title, fontsize=title_fontsize, fontweight="medium", color=INK, y=0.97)
# Combined legend — placed at bottom of figure using the 270° chart's natural gap
sector_handles = [
mlines.Line2D(
[],
[],
marker=sector_markers[s],
color="w",
markerfacecolor=sector_palette[s],
markeredgecolor=PAGE_BG,
markersize=10,
label=s,
)
for s in sectors
]
ring_sizes_legend = {"Adopt (Now)": 440, "Trial (Next)": 340, "Assess (Explore)": 250, "Hold (Watch)": 150}
ring_handles = [
mlines.Line2D(
[],
[],
marker="o",
color="w",
markerfacecolor=INK_MUTED,
markeredgecolor=PAGE_BG,
markersize=np.sqrt(sz) / 3,
label=label,
)
for label, sz in ring_sizes_legend.items()
]
legend = fig.legend(
handles=sector_handles + ring_handles,
loc="lower center",
bbox_to_anchor=(0.5, 0.01),
ncols=4,
fontsize=8,
title="Sectors (shape+color) · Time Horizons (size)",
title_fontsize=9,
framealpha=0.95,
edgecolor=INK_SOFT,
fancybox=True,
handletextpad=0.8,
borderpad=0.9,
)
legend.get_frame().set_facecolor(ELEVATED_BG)
# Save — square canvas, no bbox_inches trim (seaborn hard rule)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)
Part of Innovation Radar with Time-Horizon Rings on anyplot.ai.