{"spec_id":"radar-innovation-timeline","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nradar-innovation-timeline: Innovation Radar with Time-Horizon Rings\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 83/100 | Updated: 2026-05-29\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file from shadowing the altair package when run from its own directory\n_self_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _self_dir]\n\nfrom collections import defaultdict\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — canonical order\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\nnp.random.seed(42)\n\n# --- Configuration ---\nrings = [\"Adopt\", \"Trial\", \"Assess\", \"Hold\"]\nsectors = [\"AI & ML\", \"Cloud & Infra\", \"Data Engineering\", \"Security\"]\nn_sectors = len(sectors)\n\n# 270-degree arc layout\nstart_angle = -np.pi / 4\ntotal_arc = 1.5 * np.pi\nsector_arc = total_arc / n_sectors\n\nsector_bounds = {s: (start_angle + i * sector_arc, start_angle + (i + 1) * sector_arc) for i, s in enumerate(sectors)}\n\nring_inner = {\"Adopt\": 0.5, \"Trial\": 1.45, \"Assess\": 2.4, \"Hold\": 3.35}\nring_outer = {\"Adopt\": 1.3, \"Trial\": 2.25, \"Assess\": 3.2, \"Hold\": 4.1}\nring_boundary_radii = [1.375, 2.325, 3.275, 4.175]\n\n# Imprint palette for sectors (positions 1–4)\nsector_colors = {\n    \"AI & ML\": \"#009E73\",  # Imprint #1 — green\n    \"Cloud & Infra\": \"#C475FD\",  # Imprint #2 — lavender\n    \"Data Engineering\": \"#4467A3\",  # Imprint #3 — blue\n    \"Security\": \"#BD8233\",  # Imprint #4 — ochre\n}\n\n# Ring fills using corresponding Imprint colors at low opacity\nring_fill_colors = {\"Adopt\": \"#009E73\", \"Trial\": \"#4467A3\", \"Assess\": \"#C475FD\", \"Hold\": \"#BD8233\"}\nring_opacities = {\"Adopt\": 0.12, \"Trial\": 0.07, \"Assess\": 0.04, \"Hold\": 0.02}\nring_shapes = {\"Adopt\": \"circle\", \"Trial\": \"diamond\", \"Assess\": \"triangle-up\", \"Hold\": \"square\"}\n\n# --- Data: 26 technology items ---\nitems = [\n    {\"name\": \"LLM Agents\", \"ring\": \"Adopt\", \"sector\": \"AI & ML\"},\n    {\"name\": \"RAG Pipelines\", \"ring\": \"Adopt\", \"sector\": \"AI & ML\"},\n    {\"name\": \"Vision Models\", \"ring\": \"Trial\", \"sector\": \"AI & ML\"},\n    {\"name\": \"AI Code Review\", \"ring\": \"Trial\", \"sector\": \"AI & ML\"},\n    {\"name\": \"Neuro-symb. AI\", \"ring\": \"Assess\", \"sector\": \"AI & ML\"},\n    {\"name\": \"Auto. ML Ops\", \"ring\": \"Assess\", \"sector\": \"AI & ML\"},\n    {\"name\": \"AGI Frameworks\", \"ring\": \"Hold\", \"sector\": \"AI & ML\"},\n    {\"name\": \"Edge Computing\", \"ring\": \"Adopt\", \"sector\": \"Cloud & Infra\"},\n    {\"name\": \"Platform Eng.\", \"ring\": \"Adopt\", \"sector\": \"Cloud & Infra\"},\n    {\"name\": \"WASM Backends\", \"ring\": \"Trial\", \"sector\": \"Cloud & Infra\"},\n    {\"name\": \"FinOps Tools\", \"ring\": \"Trial\", \"sector\": \"Cloud & Infra\"},\n    {\"name\": \"Serverless GPUs\", \"ring\": \"Assess\", \"sector\": \"Cloud & Infra\"},\n    {\"name\": \"Quantum Cloud\", \"ring\": \"Hold\", \"sector\": \"Cloud & Infra\"},\n    {\"name\": \"Data Contracts\", \"ring\": \"Adopt\", \"sector\": \"Data Engineering\"},\n    {\"name\": \"Lakehouse Arch.\", \"ring\": \"Trial\", \"sector\": \"Data Engineering\"},\n    {\"name\": \"Streaming SQL\", \"ring\": \"Trial\", \"sector\": \"Data Engineering\"},\n    {\"name\": \"Data Mesh\", \"ring\": \"Assess\", \"sector\": \"Data Engineering\"},\n    {\"name\": \"Graph Analytics\", \"ring\": \"Assess\", \"sector\": \"Data Engineering\"},\n    {\"name\": \"Quantum DB\", \"ring\": \"Hold\", \"sector\": \"Data Engineering\"},\n    {\"name\": \"Zero Trust\", \"ring\": \"Adopt\", \"sector\": \"Security\"},\n    {\"name\": \"SBOM Tooling\", \"ring\": \"Adopt\", \"sector\": \"Security\"},\n    {\"name\": \"AI Threat Det.\", \"ring\": \"Trial\", \"sector\": \"Security\"},\n    {\"name\": \"Passkeys\", \"ring\": \"Trial\", \"sector\": \"Security\"},\n    {\"name\": \"Confid. Compute\", \"ring\": \"Assess\", \"sector\": \"Security\"},\n    {\"name\": \"Post-Q. Crypto\", \"ring\": \"Assess\", \"sector\": \"Security\"},\n    {\"name\": \"Homomorphic Enc.\", \"ring\": \"Hold\", \"sector\": \"Security\"},\n]\n\n# --- Position items: increased padding + stronger ring jitter to reduce overlap ---\ngroups = defaultdict(list)\nfor item in items:\n    groups[(item[\"sector\"], item[\"ring\"])].append(item)\n\nrecords = []\nfor (sector, ring), group_items in groups.items():\n    a_min, a_max = sector_bounds[sector]\n    padding = 0.28 * sector_arc  # wider padding keeps items away from sector boundaries\n    n = len(group_items)\n    r_in, r_out = ring_inner[ring], ring_outer[ring]\n    r_mid = (r_in + r_out) / 2\n    ring_idx = rings.index(ring)\n    # Alternating jitter shifts adjacent rings apart angularly\n    ring_jitter = 0.14 * sector_arc * ((-1) ** ring_idx)\n    for idx, it in enumerate(group_items):\n        angle = a_min + padding + (idx + 0.5) / n * (a_max - a_min - 2 * padding) + ring_jitter\n        r_off = 0.18 * ((-1) ** idx) if n > 1 else 0\n        radius = r_mid + r_off\n        x = radius * np.cos(angle)\n        y = radius * np.sin(angle)\n        # Alternate label radii so paired items don't stack at the same distance\n        label_r = radius + 0.55 + (idx % 2) * 0.22\n        label_x = label_r * np.cos(angle)\n        label_y = label_r * np.sin(angle)\n        records.append(\n            {\"name\": it[\"name\"], \"ring\": ring, \"sector\": sector, \"x\": x, \"y\": y, \"label_x\": label_x, \"label_y\": label_y}\n        )\n\ndf = pd.DataFrame(records)\n\n# --- Geometry: ring fills, arcs, spokes ---\nfill_pts = 80\nring_fill_rows = []\nfor rn in rings:\n    r_in, r_out = ring_inner[rn], ring_outer[rn]\n    thetas = np.linspace(start_angle, start_angle + total_arc, fill_pts)\n    for i, t in enumerate(thetas):\n        ring_fill_rows.append({\"x\": r_in * np.cos(t), \"y\": r_in * np.sin(t), \"ring\": rn, \"order\": i})\n    for i, t in enumerate(thetas[::-1]):\n        ring_fill_rows.append({\"x\": r_out * np.cos(t), \"y\": r_out * np.sin(t), \"ring\": rn, \"order\": fill_pts + i})\n    ring_fill_rows.append(\n        {\"x\": r_in * np.cos(thetas[0]), \"y\": r_in * np.sin(thetas[0]), \"ring\": rn, \"order\": 2 * fill_pts}\n    )\ndf_fills = pd.DataFrame(ring_fill_rows)\n\narc_rows = []\nfor rb in ring_boundary_radii:\n    for i, t in enumerate(np.linspace(start_angle, start_angle + total_arc, 100)):\n        arc_rows.append({\"x\": rb * np.cos(t), \"y\": rb * np.sin(t), \"rb\": rb, \"order\": i})\ndf_arcs = pd.DataFrame(arc_rows)\n\nspoke_rows = []\nfor i in range(n_sectors + 1):\n    a = start_angle + i * sector_arc\n    spoke_rows.append({\"x\": 0, \"y\": 0, \"sid\": i, \"order\": 0})\n    spoke_rows.append({\"x\": 4.35 * np.cos(a), \"y\": 4.35 * np.sin(a), \"sid\": i, \"order\": 1})\ndf_spokes = pd.DataFrame(spoke_rows)\n\n# Sector headers at the outer edge\nsec_r = 4.95\ndf_sec = pd.DataFrame(\n    [\n        {\n            \"x\": sec_r * np.cos(start_angle + (i + 0.5) * sector_arc),\n            \"y\": sec_r * np.sin(start_angle + (i + 0.5) * sector_arc),\n            \"sector\": s,\n        }\n        for i, s in enumerate(sectors)\n    ]\n)\n\n# Ring labels in the gap area at the bottom of the arc\ngap_angle = 3 * np.pi / 2\ndf_rlabels = pd.DataFrame(\n    [\n        {\n            \"x\": (ring_inner[rn] + ring_outer[rn]) / 2 * np.cos(gap_angle) + 0.22,\n            \"y\": (ring_inner[rn] + ring_outer[rn]) / 2 * np.sin(gap_angle),\n            \"ring\": rn,\n        }\n        for rn in rings\n    ]\n)\n\n# --- Altair chart assembly ---\ndom = [-6.4, 6.4]\nx_enc = alt.X(\"x:Q\", scale=alt.Scale(domain=dom), axis=None)\ny_enc = alt.Y(\"y:Q\", scale=alt.Scale(domain=dom), axis=None)\n\ncolor_scale = alt.Scale(domain=list(sector_colors), range=list(sector_colors.values()))\nshape_scale = alt.Scale(domain=rings, range=[ring_shapes[r] for r in rings])\n\nhover = alt.selection_point(on=\"pointerover\", fields=[\"name\"], nearest=True, empty=False)\n\n# Ring fill bands\nfill_layers = [\n    alt.Chart(df_fills[df_fills[\"ring\"] == rn])\n    .mark_line(strokeWidth=0, filled=True, fill=ring_fill_colors[rn], fillOpacity=ring_opacities[rn])\n    .encode(x=x_enc, y=y_enc, order=\"order:Q\")\n    for rn in rings\n]\n\n# Ring boundary arcs\narcs = (\n    alt.Chart(df_arcs)\n    .mark_line(strokeWidth=0.9, stroke=INK_SOFT, opacity=0.30)\n    .encode(x=x_enc, y=y_enc, detail=\"rb:N\", order=\"order:Q\")\n)\n\n# Sector spokes\nspokes = (\n    alt.Chart(df_spokes)\n    .mark_line(strokeWidth=0.9, stroke=INK_MUTED, opacity=0.30)\n    .encode(x=x_enc, y=y_enc, detail=\"sid:N\", order=\"order:Q\")\n)\n\n# Leader lines from markers to labels (single rule layer — avoids 3-layer label split)\nleaders = (\n    alt.Chart(df)\n    .mark_rule(strokeWidth=0.55, opacity=0.20)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=dom), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=dom), axis=None),\n        x2=\"label_x:Q\",\n        y2=\"label_y:Q\",\n        color=alt.value(INK_MUTED),\n    )\n)\n\n# Data points with hover-driven size highlight and explicit dual legend\npoints = (\n    alt.Chart(df)\n    .mark_point(filled=True, strokeWidth=1.2, stroke=PAGE_BG, opacity=0.92)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        color=alt.Color(\"sector:N\", scale=color_scale, legend=alt.Legend(title=\"Sector\")),\n        shape=alt.Shape(\"ring:N\", scale=shape_scale, legend=alt.Legend(title=\"Ring\")),\n        size=alt.condition(hover, alt.value(380), alt.value(200)),\n        tooltip=[\"name:N\", \"sector:N\", \"ring:N\"],\n    )\n    .add_params(hover)\n)\n\n# Single unified label layer (center alignment — eliminates 3-layer split)\nlabels = (\n    alt.Chart(df)\n    .mark_text(fontSize=10, fontWeight=\"normal\", align=\"center\", baseline=\"middle\")\n    .encode(\n        x=alt.X(\"label_x:Q\", scale=alt.Scale(domain=dom), axis=None),\n        y=alt.Y(\"label_y:Q\", scale=alt.Scale(domain=dom), axis=None),\n        text=\"name:N\",\n        color=alt.value(INK_SOFT),\n        opacity=alt.condition(hover, alt.value(1.0), alt.value(0.82)),\n    )\n)\n\n# Sector headers: one layer per sector with hardcoded color (avoids legend channel conflict)\nsec_header_layers = [\n    alt.Chart(df_sec[df_sec[\"sector\"] == s])\n    .mark_text(fontSize=12, fontWeight=\"bold\", color=sector_colors[s])\n    .encode(x=x_enc, y=y_enc, text=\"sector:N\")\n    for s in sectors\n]\n\n# Ring labels in the gap area\nrlabels = (\n    alt.Chart(df_rlabels)\n    .mark_text(fontSize=10, fontWeight=\"bold\", align=\"left\", baseline=\"middle\")\n    .encode(x=x_enc, y=y_enc, text=\"ring:N\", color=alt.value(INK_MUTED))\n)\n\nchart = (\n    alt.layer(*fill_layers, arcs, spokes, leaders, points, labels, *sec_header_layers, rlabels)\n    .properties(\n        width=500,\n        height=410,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"radar-innovation-timeline · python · altair · anyplot.ai\",\n            fontSize=14,\n            anchor=\"middle\",\n            offset=14,\n            color=INK,\n        ),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_legend(\n        padding=8,\n        cornerRadius=4,\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=12,\n        orient=\"bottom\",\n        direction=\"horizontal\",\n        symbolSize=180,\n        symbolStrokeWidth=0,\n    )\n)\n\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Pad to exact 2400×2400 target (square — radar chart)\nTW, TH = 2400, 2400\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}