{"spec_id":"radar-innovation-timeline","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nradar-innovation-timeline: Innovation Radar with Time-Horizon Rings\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-29\n\"\"\"\n\nimport os\nimport time\nfrom collections import defaultdict\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Legend, LegendItem\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Imprint palette — theme-adaptive chrome\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint categorical palette — 8 hues, canonical order, theme-independent\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Sector colors: first 4 Imprint positions (#009E73 is always first series)\nsector_colors = IMPRINT_PALETTE[:4]\n\n# Data — Technology innovation radar\nnp.random.seed(42)\n\nsectors = [\"AI & ML\", \"Cloud & Infra\", \"Sustainability\", \"Biotech\"]\nrings = [\"Adopt\", \"Trial\", \"Assess\", \"Hold\"]\n\nring_inner = [0, 105, 200, 295]\nring_outer = [105, 200, 295, 390]\nring_mid = [(i + o) / 2 for i, o in zip(ring_inner, ring_outer, strict=True)]\n\n# Visual hierarchy: near-term items are larger/bolder\nring_marker_sizes = [28, 22, 18, 14]\n\nitems = [\n    # AI & ML (sector 0) — Imprint green #009E73\n    (\"LLM Agents\", 0, 0),\n    (\"RAG Pipelines\", 0, 0),\n    (\"Vision Models\", 1, 0),\n    (\"AI Code Review\", 1, 0),\n    (\"Neuro-Symbolic AI\", 2, 0),\n    (\"Quantum ML\", 3, 0),\n    (\"Autonomous Research\", 3, 0),\n    # Cloud & Infra (sector 1) — Imprint lavender #C475FD\n    (\"K8s GitOps\", 0, 1),\n    (\"Edge Computing\", 0, 1),\n    (\"WebAssembly\", 1, 1),\n    (\"Serverless GPUs\", 1, 1),\n    (\"eBPF Networking\", 2, 1),\n    (\"Confidential Compute\", 2, 1),\n    (\"Satellite Internet\", 3, 1),\n    # Sustainability (sector 2) — Imprint blue #4467A3\n    (\"Carbon Tracking\", 0, 2),\n    (\"Green Cloud\", 1, 2),\n    (\"Circular Supply Chain\", 1, 2),\n    (\"Smart Grid AI\", 2, 2),\n    (\"Ocean Carbon Capture\", 3, 2),\n    (\"Fusion Energy\", 3, 2),\n    # Biotech (sector 3) — Imprint ochre #BD8233\n    (\"mRNA Therapeutics\", 0, 3),\n    (\"CRISPR Diagnostics\", 1, 3),\n    (\"Digital Twins (Health)\", 2, 3),\n    (\"Organ-on-Chip\", 2, 3),\n    (\"Synthetic Biology\", 3, 3),\n    (\"Brain-Computer Interface\", 3, 3),\n]\n\nn_sectors = len(sectors)\n\n# 270-degree layout; gap in upper-right holds ring labels and legend\ntotal_angle = 3 * np.pi / 2\nstart_angle = np.pi / 4\nsector_width = total_angle / n_sectors\nsector_starts = [start_angle + i * sector_width for i in range(n_sectors)]\nsector_ends = [start_angle + (i + 1) * sector_width for i in range(n_sectors)]\n\n# Square canvas 2400×2400 (symmetric radar)\nW, H = 2400, 2400\n\np = figure(\n    width=W,\n    height=H,\n    title=\"radar-innovation-timeline · python · bokeh · anyplot.ai\",\n    x_range=(-470, 470),\n    y_range=(-460, 480),\n    tools=\"\",\n    toolbar_location=None,  # omit toolbar so PNG height matches H exactly\n    min_border_top=120,\n    min_border_bottom=60,\n    min_border_left=60,\n    min_border_right=60,\n)\n\n# Theme-adaptive chrome\np.axis.visible = False\np.grid.visible = False\np.outline_line_color = None\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.title.text_font_size = \"50pt\"\np.title.align = \"center\"\np.title.text_color = INK\n\n# Alternating ring background fills — subtle bands separate time horizons\nfor ring_idx in range(len(rings)):\n    r_out = ring_outer[ring_idx]\n    r_in = ring_inner[ring_idx]\n    theta = np.linspace(start_angle, start_angle + total_angle, 200)\n    x_out = r_out * np.cos(theta)\n    y_out = r_out * np.sin(theta)\n    x_in = r_in * np.cos(theta[::-1])\n    y_in = r_in * np.sin(theta[::-1])\n    fill_alpha = 0.10 if ring_idx % 2 == 0 else 0.04\n    p.patch(\n        np.concatenate([x_out, x_in]).tolist(),\n        np.concatenate([y_out, y_in]).tolist(),\n        fill_color=INK_SOFT,\n        fill_alpha=fill_alpha,\n        line_color=None,\n    )\n\n# Sector-colored overlays per ring-sector cell for visual grouping\nfor ring_idx in range(len(rings)):\n    for sector_idx in range(n_sectors):\n        r_out = ring_outer[ring_idx]\n        r_in = ring_inner[ring_idx]\n        theta_seg = np.linspace(sector_starts[sector_idx], sector_ends[sector_idx], 50)\n        x_out = r_out * np.cos(theta_seg)\n        y_out = r_out * np.sin(theta_seg)\n        x_in = r_in * np.cos(theta_seg[::-1])\n        y_in = r_in * np.sin(theta_seg[::-1])\n        p.patch(\n            np.concatenate([x_out, x_in]).tolist(),\n            np.concatenate([y_out, y_in]).tolist(),\n            fill_color=sector_colors[sector_idx],\n            fill_alpha=0.06,\n            line_color=None,\n        )\n\n# Ring boundary arcs (dashed)\nfor r in ring_outer:\n    theta = np.linspace(start_angle, start_angle + total_angle, 200)\n    p.line(\n        (r * np.cos(theta)).tolist(),\n        (r * np.sin(theta)).tolist(),\n        line_color=INK_SOFT,\n        line_width=2,\n        line_alpha=0.4,\n        line_dash=[6, 4],\n    )\n\n# Sector divider lines from center to outer ring\nfor i in range(n_sectors + 1):\n    angle = start_angle + i * sector_width\n    p.line(\n        [0, ring_outer[-1] * np.cos(angle)],\n        [0, ring_outer[-1] * np.sin(angle)],\n        line_color=INK_SOFT,\n        line_width=1.5,\n        line_alpha=0.45,\n    )\n\n# Ring labels in the gap area (upper-right, just beyond the arc end)\nend_angle = start_angle + total_angle\nlabel_angle = end_angle + 0.08\nfor ring_idx, ring_name in enumerate(rings):\n    r = ring_mid[ring_idx]\n    p.add_layout(\n        Label(\n            x=r * np.cos(label_angle),\n            y=r * np.sin(label_angle),\n            text=ring_name,\n            text_font_size=\"22pt\",\n            text_color=INK_SOFT,\n            text_font_style=\"bold\",\n            text_align=\"center\",\n            text_baseline=\"middle\",\n            background_fill_color=ELEVATED_BG,\n            background_fill_alpha=0.85,\n        )\n    )\n\n# Sector header labels along the outer edge\nfor i, sector_name in enumerate(sectors):\n    mid_angle = (sector_starts[i] + sector_ends[i]) / 2\n    label_r = ring_outer[-1] + 45\n    x_pos = label_r * np.cos(mid_angle)\n    y_pos = label_r * np.sin(mid_angle)\n    cos_val = np.cos(mid_angle)\n    text_align = \"center\" if abs(cos_val) < 0.3 else (\"left\" if cos_val > 0 else \"right\")\n    p.add_layout(\n        Label(\n            x=x_pos,\n            y=y_pos,\n            text=sector_name,\n            text_font_size=\"26pt\",\n            text_color=sector_colors[i],\n            text_font_style=\"bold\",\n            text_align=text_align,\n            text_baseline=\"middle\",\n        )\n    )\n\n# Precompute item positions\ngroups: dict = defaultdict(list)\nfor idx, (_name, ring_idx, sector_idx) in enumerate(items):\n    groups[(ring_idx, sector_idx)].append(idx)\n\nxs, ys, colors, sizes, names, ring_names, sector_names = [], [], [], [], [], [], []\n\nfor idx, (name, ring_idx, sector_idx) in enumerate(items):\n    group = groups[(ring_idx, sector_idx)]\n    pos_in_group = group.index(idx)\n    n_in_group = len(group)\n\n    s_start = sector_starts[sector_idx]\n    margin = sector_width * 0.15  # wider margin reduces crowding near sector edges\n    usable_start = s_start + margin\n    usable_end = sector_ends[sector_idx] - margin\n\n    if n_in_group == 1:\n        angle = (usable_start + usable_end) / 2\n    else:\n        angle = usable_start + (usable_end - usable_start) * pos_in_group / (n_in_group - 1)\n\n    r_base = ring_mid[ring_idx]\n    ring_hw = (ring_outer[ring_idx] - ring_inner[ring_idx]) / 2\n    r = r_base + np.random.uniform(-ring_hw * 0.35, ring_hw * 0.35)\n\n    x = r * np.cos(angle)\n    y = r * np.sin(angle)\n\n    xs.append(x)\n    ys.append(y)\n    colors.append(sector_colors[sector_idx])\n    sizes.append(ring_marker_sizes[ring_idx])\n    names.append(name)\n    ring_names.append(rings[ring_idx])\n    sector_names.append(sectors[sector_idx])\n\n    # Label placement: alternate inward/outward in crowded inner rings\n    base_offsets = [26, 20, 16, 13]\n    if ring_idx == 0 and n_in_group > 1:\n        direction = 1 if pos_in_group % 2 == 0 else -0.6\n        label_r_offset = base_offsets[ring_idx] * direction\n    else:\n        label_r_offset = base_offsets[ring_idx]\n\n    lx = x + label_r_offset * np.cos(angle)\n    ly = y + label_r_offset * np.sin(angle)\n\n    cos_val = np.cos(angle)\n    text_align = \"center\" if abs(cos_val) < 0.3 else (\"left\" if cos_val > 0 else \"right\")\n    sin_val = np.sin(angle)\n    text_baseline = \"middle\" if abs(sin_val) < 0.3 else (\"bottom\" if sin_val > 0 else \"top\")\n\n    p.add_layout(\n        Label(\n            x=lx,\n            y=ly,\n            text=name,\n            text_font_size=\"16pt\",\n            text_color=INK,\n            text_align=text_align,\n            text_baseline=text_baseline,\n        )\n    )\n\n# Scatter markers per sector (needed for per-sector legend items)\nlegend_items_list = []\nfor si, sector_name in enumerate(sectors):\n    indices = [i for i, (_, _, sec_idx) in enumerate(items) if sec_idx == si]\n    sector_source = ColumnDataSource(\n        data={\n            \"x\": [xs[i] for i in indices],\n            \"y\": [ys[i] for i in indices],\n            \"color\": [colors[i] for i in indices],\n            \"size\": [sizes[i] for i in indices],\n            \"name\": [names[i] for i in indices],\n            \"ring\": [ring_names[i] for i in indices],\n            \"sector\": [sector_names[i] for i in indices],\n        }\n    )\n    renderer = p.scatter(\n        \"x\",\n        \"y\",\n        source=sector_source,\n        size=\"size\",\n        fill_color=\"color\",\n        line_color=PAGE_BG,  # theme-adaptive marker edge\n        line_width=2.5,\n        alpha=0.9,\n    )\n    legend_items_list.append(LegendItem(label=sector_name, renderers=[renderer]))\n\n# HoverTool — Bokeh's distinctive interactive feature\nhover = HoverTool(\n    tooltips=[(\"Technology\", \"@name\"), (\"Horizon\", \"@ring\"), (\"Sector\", \"@sector\")], point_policy=\"snap_to_data\"\n)\np.add_tools(hover)\n\n# Legend inside the figure, placed in the upper-right gap area\nlegend = Legend(\n    items=legend_items_list,\n    location=\"top_right\",\n    label_text_font_size=\"22pt\",\n    label_text_color=INK_SOFT,\n    glyph_height=32,\n    glyph_width=32,\n    spacing=16,\n    padding=22,\n    background_fill_color=ELEVATED_BG,\n    background_fill_alpha=0.92,\n    border_line_color=INK_SOFT,\n    border_line_width=1.5,\n)\np.add_layout(legend)  # inside figure, not side panel — fits within 2400×2400 canvas\n\n# Save interactive HTML\noutput_file(f\"plot-{THEME}.html\", title=\"radar-innovation-timeline · python · bokeh · anyplot.ai\")\nsave(p)\n\n# Screenshot with headless Chrome (avoids export_png / chromedriver snap issues)\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H + 200}\",  # outer window larger; CDP overrides inner viewport\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\n# Force exact viewport via CDP — set_window_size sets the outer window, which\n# leaves the inner viewport ~139px shorter than requested on this environment.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)  # let bokeh's JS render the canvas\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}