Innovation Radar with Time-Horizon Rings — Bokeh

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.

Innovation Radar with Time-Horizon Rings rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
radar-innovation-timeline: Innovation Radar with Time-Horizon Rings
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 87/100 | Updated: 2026-05-29
"""

import os
import time
from collections import defaultdict
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool, Label, Legend, LegendItem
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


THEME = os.getenv("ANYPLOT_THEME", "light")

# Imprint palette — theme-adaptive chrome
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"

# Imprint categorical palette — 8 hues, canonical order, theme-independent
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Sector colors: first 4 Imprint positions (#009E73 is always first series)
sector_colors = IMPRINT_PALETTE[:4]

# Data — Technology innovation radar
np.random.seed(42)

sectors = ["AI & ML", "Cloud & Infra", "Sustainability", "Biotech"]
rings = ["Adopt", "Trial", "Assess", "Hold"]

ring_inner = [0, 105, 200, 295]
ring_outer = [105, 200, 295, 390]
ring_mid = [(i + o) / 2 for i, o in zip(ring_inner, ring_outer, strict=True)]

# Visual hierarchy: near-term items are larger/bolder
ring_marker_sizes = [28, 22, 18, 14]

items = [
    # AI & ML (sector 0) — Imprint green #009E73
    ("LLM Agents", 0, 0),
    ("RAG Pipelines", 0, 0),
    ("Vision Models", 1, 0),
    ("AI Code Review", 1, 0),
    ("Neuro-Symbolic AI", 2, 0),
    ("Quantum ML", 3, 0),
    ("Autonomous Research", 3, 0),
    # Cloud & Infra (sector 1) — Imprint lavender #C475FD
    ("K8s GitOps", 0, 1),
    ("Edge Computing", 0, 1),
    ("WebAssembly", 1, 1),
    ("Serverless GPUs", 1, 1),
    ("eBPF Networking", 2, 1),
    ("Confidential Compute", 2, 1),
    ("Satellite Internet", 3, 1),
    # Sustainability (sector 2) — Imprint blue #4467A3
    ("Carbon Tracking", 0, 2),
    ("Green Cloud", 1, 2),
    ("Circular Supply Chain", 1, 2),
    ("Smart Grid AI", 2, 2),
    ("Ocean Carbon Capture", 3, 2),
    ("Fusion Energy", 3, 2),
    # Biotech (sector 3) — Imprint ochre #BD8233
    ("mRNA Therapeutics", 0, 3),
    ("CRISPR Diagnostics", 1, 3),
    ("Digital Twins (Health)", 2, 3),
    ("Organ-on-Chip", 2, 3),
    ("Synthetic Biology", 3, 3),
    ("Brain-Computer Interface", 3, 3),
]

n_sectors = len(sectors)

# 270-degree layout; gap in upper-right holds ring labels and legend
total_angle = 3 * np.pi / 2
start_angle = np.pi / 4
sector_width = total_angle / n_sectors
sector_starts = [start_angle + i * sector_width for i in range(n_sectors)]
sector_ends = [start_angle + (i + 1) * sector_width for i in range(n_sectors)]

# Square canvas 2400×2400 (symmetric radar)
W, H = 2400, 2400

p = figure(
    width=W,
    height=H,
    title="radar-innovation-timeline · python · bokeh · anyplot.ai",
    x_range=(-470, 470),
    y_range=(-460, 480),
    tools="",
    toolbar_location=None,  # omit toolbar so PNG height matches H exactly
    min_border_top=120,
    min_border_bottom=60,
    min_border_left=60,
    min_border_right=60,
)

# Theme-adaptive chrome
p.axis.visible = False
p.grid.visible = False
p.outline_line_color = None
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.title.text_font_size = "50pt"
p.title.align = "center"
p.title.text_color = INK

# Alternating ring background fills — subtle bands separate time horizons
for ring_idx in range(len(rings)):
    r_out = ring_outer[ring_idx]
    r_in = ring_inner[ring_idx]
    theta = np.linspace(start_angle, start_angle + total_angle, 200)
    x_out = r_out * np.cos(theta)
    y_out = r_out * np.sin(theta)
    x_in = r_in * np.cos(theta[::-1])
    y_in = r_in * np.sin(theta[::-1])
    fill_alpha = 0.10 if ring_idx % 2 == 0 else 0.04
    p.patch(
        np.concatenate([x_out, x_in]).tolist(),
        np.concatenate([y_out, y_in]).tolist(),
        fill_color=INK_SOFT,
        fill_alpha=fill_alpha,
        line_color=None,
    )

# Sector-colored overlays per ring-sector cell for visual grouping
for ring_idx in range(len(rings)):
    for sector_idx in range(n_sectors):
        r_out = ring_outer[ring_idx]
        r_in = ring_inner[ring_idx]
        theta_seg = np.linspace(sector_starts[sector_idx], sector_ends[sector_idx], 50)
        x_out = r_out * np.cos(theta_seg)
        y_out = r_out * np.sin(theta_seg)
        x_in = r_in * np.cos(theta_seg[::-1])
        y_in = r_in * np.sin(theta_seg[::-1])
        p.patch(
            np.concatenate([x_out, x_in]).tolist(),
            np.concatenate([y_out, y_in]).tolist(),
            fill_color=sector_colors[sector_idx],
            fill_alpha=0.06,
            line_color=None,
        )

# Ring boundary arcs (dashed)
for r in ring_outer:
    theta = np.linspace(start_angle, start_angle + total_angle, 200)
    p.line(
        (r * np.cos(theta)).tolist(),
        (r * np.sin(theta)).tolist(),
        line_color=INK_SOFT,
        line_width=2,
        line_alpha=0.4,
        line_dash=[6, 4],
    )

# Sector divider lines from center to outer ring
for i in range(n_sectors + 1):
    angle = start_angle + i * sector_width
    p.line(
        [0, ring_outer[-1] * np.cos(angle)],
        [0, ring_outer[-1] * np.sin(angle)],
        line_color=INK_SOFT,
        line_width=1.5,
        line_alpha=0.45,
    )

# Ring labels in the gap area (upper-right, just beyond the arc end)
end_angle = start_angle + total_angle
label_angle = end_angle + 0.08
for ring_idx, ring_name in enumerate(rings):
    r = ring_mid[ring_idx]
    p.add_layout(
        Label(
            x=r * np.cos(label_angle),
            y=r * np.sin(label_angle),
            text=ring_name,
            text_font_size="22pt",
            text_color=INK_SOFT,
            text_font_style="bold",
            text_align="center",
            text_baseline="middle",
            background_fill_color=ELEVATED_BG,
            background_fill_alpha=0.85,
        )
    )

# Sector header labels along the outer edge
for i, sector_name in enumerate(sectors):
    mid_angle = (sector_starts[i] + sector_ends[i]) / 2
    label_r = ring_outer[-1] + 45
    x_pos = label_r * np.cos(mid_angle)
    y_pos = label_r * np.sin(mid_angle)
    cos_val = np.cos(mid_angle)
    text_align = "center" if abs(cos_val) < 0.3 else ("left" if cos_val > 0 else "right")
    p.add_layout(
        Label(
            x=x_pos,
            y=y_pos,
            text=sector_name,
            text_font_size="26pt",
            text_color=sector_colors[i],
            text_font_style="bold",
            text_align=text_align,
            text_baseline="middle",
        )
    )

# Precompute item positions
groups: dict = defaultdict(list)
for idx, (_name, ring_idx, sector_idx) in enumerate(items):
    groups[(ring_idx, sector_idx)].append(idx)

xs, ys, colors, sizes, names, ring_names, sector_names = [], [], [], [], [], [], []

for idx, (name, ring_idx, sector_idx) in enumerate(items):
    group = groups[(ring_idx, sector_idx)]
    pos_in_group = group.index(idx)
    n_in_group = len(group)

    s_start = sector_starts[sector_idx]
    margin = sector_width * 0.15  # wider margin reduces crowding near sector edges
    usable_start = s_start + margin
    usable_end = sector_ends[sector_idx] - margin

    if n_in_group == 1:
        angle = (usable_start + usable_end) / 2
    else:
        angle = usable_start + (usable_end - usable_start) * pos_in_group / (n_in_group - 1)

    r_base = ring_mid[ring_idx]
    ring_hw = (ring_outer[ring_idx] - ring_inner[ring_idx]) / 2
    r = r_base + np.random.uniform(-ring_hw * 0.35, ring_hw * 0.35)

    x = r * np.cos(angle)
    y = r * np.sin(angle)

    xs.append(x)
    ys.append(y)
    colors.append(sector_colors[sector_idx])
    sizes.append(ring_marker_sizes[ring_idx])
    names.append(name)
    ring_names.append(rings[ring_idx])
    sector_names.append(sectors[sector_idx])

    # Label placement: alternate inward/outward in crowded inner rings
    base_offsets = [26, 20, 16, 13]
    if ring_idx == 0 and n_in_group > 1:
        direction = 1 if pos_in_group % 2 == 0 else -0.6
        label_r_offset = base_offsets[ring_idx] * direction
    else:
        label_r_offset = base_offsets[ring_idx]

    lx = x + label_r_offset * np.cos(angle)
    ly = y + label_r_offset * np.sin(angle)

    cos_val = np.cos(angle)
    text_align = "center" if abs(cos_val) < 0.3 else ("left" if cos_val > 0 else "right")
    sin_val = np.sin(angle)
    text_baseline = "middle" if abs(sin_val) < 0.3 else ("bottom" if sin_val > 0 else "top")

    p.add_layout(
        Label(
            x=lx,
            y=ly,
            text=name,
            text_font_size="16pt",
            text_color=INK,
            text_align=text_align,
            text_baseline=text_baseline,
        )
    )

# Scatter markers per sector (needed for per-sector legend items)
legend_items_list = []
for si, sector_name in enumerate(sectors):
    indices = [i for i, (_, _, sec_idx) in enumerate(items) if sec_idx == si]
    sector_source = ColumnDataSource(
        data={
            "x": [xs[i] for i in indices],
            "y": [ys[i] for i in indices],
            "color": [colors[i] for i in indices],
            "size": [sizes[i] for i in indices],
            "name": [names[i] for i in indices],
            "ring": [ring_names[i] for i in indices],
            "sector": [sector_names[i] for i in indices],
        }
    )
    renderer = p.scatter(
        "x",
        "y",
        source=sector_source,
        size="size",
        fill_color="color",
        line_color=PAGE_BG,  # theme-adaptive marker edge
        line_width=2.5,
        alpha=0.9,
    )
    legend_items_list.append(LegendItem(label=sector_name, renderers=[renderer]))

# HoverTool — Bokeh's distinctive interactive feature
hover = HoverTool(
    tooltips=[("Technology", "@name"), ("Horizon", "@ring"), ("Sector", "@sector")], point_policy="snap_to_data"
)
p.add_tools(hover)

# Legend inside the figure, placed in the upper-right gap area
legend = Legend(
    items=legend_items_list,
    location="top_right",
    label_text_font_size="22pt",
    label_text_color=INK_SOFT,
    glyph_height=32,
    glyph_width=32,
    spacing=16,
    padding=22,
    background_fill_color=ELEVATED_BG,
    background_fill_alpha=0.92,
    border_line_color=INK_SOFT,
    border_line_width=1.5,
)
p.add_layout(legend)  # inside figure, not side panel — fits within 2400×2400 canvas

# Save interactive HTML
output_file(f"plot-{THEME}.html", title="radar-innovation-timeline · python · bokeh · anyplot.ai")
save(p)

# Screenshot with headless Chrome (avoids export_png / chromedriver snap issues)
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H + 200}",  # outer window larger; CDP overrides inner viewport
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
# Force exact viewport via CDP — set_window_size sets the outer window, which
# leaves the inner viewport ~139px shorter than requested on this environment.
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)  # let bokeh's JS render the canvas
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Part of Innovation Radar with Time-Horizon Rings on anyplot.ai.

Other implementations