{"spec_id":"radar-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nradar-basic: Basic Radar Chart\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-07-24\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, LabelSet, 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\")\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 (canonical order) - theme-independent\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data - employee performance review across core competencies (0-100 scale)\ncategories = [\"Communication\", \"Technical Skills\", \"Teamwork\", \"Problem Solving\", \"Leadership\", \"Creativity\"]\nemployees = {\n    \"Employee A\": [85, 90, 75, 88, 70, 82],\n    \"Employee B\": [70, 75, 90, 72, 85, 78],\n    \"Employee C\": [92, 65, 80, 68, 60, 95],\n}\n\nn_categories = len(categories)\nangles = np.linspace(0, 2 * np.pi, n_categories, endpoint=False).tolist()\nangles_closed = angles + [angles[0]]\n\nR_MAX = 100  # outer gridline radius\nLABEL_R = 108  # category label radius (just outside the outer gridline)\nAXIS_LIM = 190  # equal x/y domain so gridline circles render as true circles\n\nW = H = 2400\np = figure(\n    width=W,\n    height=H,\n    title=\"radar-basic · bokeh · anyplot.ai\",\n    x_range=(-AXIS_LIM, AXIS_LIM),\n    y_range=(-AXIS_LIM, AXIS_LIM),\n    tools=\"\",\n    toolbar_location=None,  # avoids the ~30-50px toolbar row shrinking the saved PNG\n    min_border_top=130,\n    min_border_bottom=40,\n    min_border_left=40,\n    min_border_right=40,\n)\n\n# Concentric gridlines at 20/40/60/80/100 with radius labels along the top spoke\ntheta = np.linspace(0, 2 * np.pi, 100)\nfor r in [20, 40, 60, 80, 100]:\n    p.line(r * np.cos(theta), r * np.sin(theta), line_color=INK, line_alpha=0.15, line_width=2)\nscale_source = ColumnDataSource(\n    data={\"x\": [3] * 5, \"y\": [20, 40, 60, 80, 100], \"text\": [str(r) for r in [20, 40, 60, 80, 100]]}\n)\nscale_labels = LabelSet(\n    x=\"x\",\n    y=\"y\",\n    text=\"text\",\n    source=scale_source,\n    text_font_size=\"28pt\",\n    text_align=\"left\",\n    text_baseline=\"middle\",\n    text_color=INK_SOFT,\n    background_fill_color=PAGE_BG,\n    background_fill_alpha=0.85,\n)\np.add_layout(scale_labels)\n\n# Axis spokes from center to each category\nfor angle in angles:\n    p.line([0, R_MAX * np.cos(angle)], [0, R_MAX * np.sin(angle)], line_color=INK, line_alpha=0.15, line_width=2)\n\n# Category labels at the outer edge\nfor angle, cat in zip(angles, categories, strict=True):\n    x_label = LABEL_R * np.cos(angle)\n    y_label = LABEL_R * np.sin(angle)\n    if abs(np.cos(angle)) < 0.15:\n        text_align = \"center\"\n    elif np.cos(angle) > 0:\n        text_align = \"left\"\n    else:\n        text_align = \"right\"\n    p.text(\n        x=[x_label],\n        y=[y_label],\n        text=[cat],\n        text_font_size=\"36pt\",\n        text_align=text_align,\n        text_baseline=\"middle\",\n        text_color=INK,\n    )\n\n# Filled polygons for each employee\nlegend_items = []\nhover_renderers = []\nfor i, (name, values) in enumerate(employees.items()):\n    values_closed = values + [values[0]]\n    x = [v * np.cos(a) for v, a in zip(values_closed, angles_closed, strict=True)]\n    y = [v * np.sin(a) for v, a in zip(values_closed, angles_closed, strict=True)]\n    color = IMPRINT_PALETTE[i]\n    source = ColumnDataSource(\n        data={\n            \"x\": x,\n            \"y\": y,\n            \"employee\": [name] * len(x),\n            \"category\": [*categories, categories[0]],\n            \"value\": values_closed,\n        }\n    )\n    patch = p.patch(\"x\", \"y\", source=source, fill_color=color, fill_alpha=0.2, line_color=color, line_width=5)\n    scatter = p.scatter(\"x\", \"y\", source=source, size=32, color=color, line_color=PAGE_BG, line_width=2)\n    legend_items.append(LegendItem(label=name, renderers=[patch, scatter]))\n    hover_renderers.append(scatter)\n\n# Hover tooltips - the interactive HTML surfaces exact scores per vertex\nhover = HoverTool(\n    renderers=hover_renderers, tooltips=[(\"Employee\", \"@employee\"), (\"Category\", \"@category\"), (\"Score\", \"@value\")]\n)\np.add_tools(hover)\n\nlegend = Legend(items=legend_items, location=\"top_right\")\nlegend.label_text_font_size = \"34pt\"\nlegend.glyph_height = 40\nlegend.glyph_width = 40\nlegend.spacing = 14\nlegend.background_fill_color = ELEVATED_BG\nlegend.border_line_color = INK_SOFT\nlegend.label_text_color = INK_SOFT\nlegend.background_fill_alpha = 0.9\np.add_layout(legend)\n\n# Style the plot\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.align = \"center\"\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\np.outline_line_color = None\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\n# Write the interactive HTML (required catalog artifact)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot it with headless Chrome - export_png's chromedriver probe is\n# unreliable in this environment, so render via Selenium instead.\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}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\n# Headless Chrome's --window-size sets the OUTER window; pin the viewport\n# exactly via CDP so the screenshot matches W x H precisely.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ntime.sleep(3)  # let bokeh's JS render the canvas\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}