{"spec_id":"radar-multi","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nradar-multi: Multi-Series Radar Chart\nLibrary: altair 6.2.2 | Python 3.13.15\nQuality: 90/100 | Updated: 2026-08-17\n\"\"\"\n\nimport importlib.util\nimport os\nimport sys\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Explicitly import altair from site-packages to avoid shadowing\nspec = importlib.util.find_spec(\"altair\")\nif spec and spec.origin and \"site-packages\" in spec.origin:\n    alt = importlib.util.module_from_spec(spec)\n    sys.modules[\"altair\"] = alt\n    spec.loader.exec_module(alt)\nelse:\n    # Fallback: remove the directory containing this script from path\n    script_dir = os.path.dirname(os.path.abspath(__file__))\n    sys.path[:] = [p for p in sys.path if os.path.abspath(p) != script_dir]\n    import altair as alt\n\n\n# Theme tokens\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 palette (first series ALWAYS #009E73)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Data: Product comparison across key attributes\ncategories = [\"Price\", \"Quality\", \"Durability\", \"Support\", \"Features\", \"Design\"]\nn_categories = len(categories)\n\ndata = {\n    \"Product A\": [85, 70, 90, 65, 80, 75],\n    \"Product B\": [60, 85, 75, 90, 70, 80],\n    \"Product C\": [75, 65, 80, 70, 95, 85],\n}\n\n# Calculate angles for each axis (in radians) - start from top\nangles = [np.pi / 2 - i * 2 * np.pi / n_categories for i in range(n_categories)]\n\n# Build records for each series\nrecords = []\nfor series_name, values in data.items():\n    for i, (cat, val, angle) in enumerate(zip(categories, values, angles, strict=True)):\n        # Convert polar to cartesian for plotting\n        x = val * np.cos(angle)\n        y = val * np.sin(angle)\n        records.append(\n            {\"series\": series_name, \"category\": cat, \"value\": val, \"angle\": angle, \"x\": x, \"y\": y, \"order\": i}\n        )\n    # Close the polygon by adding the first point again\n    first_val = values[0]\n    first_angle = angles[0]\n    records.append(\n        {\n            \"series\": series_name,\n            \"category\": categories[0],\n            \"value\": first_val,\n            \"angle\": first_angle,\n            \"x\": first_val * np.cos(first_angle),\n            \"y\": first_val * np.sin(first_angle),\n            \"order\": n_categories,\n        }\n    )\n\ndf = pd.DataFrame(records)\n\n# Create gridlines (hexagonal matching the axes)\ngrid_records = []\nfor r in [20, 40, 60, 80, 100]:\n    for i, angle in enumerate(angles):\n        grid_records.append({\"radius\": r, \"x\": r * np.cos(angle), \"y\": r * np.sin(angle), \"order\": i})\n    # Close the hexagon\n    grid_records.append({\"radius\": r, \"x\": r * np.cos(angles[0]), \"y\": r * np.sin(angles[0]), \"order\": n_categories})\ngrid_df = pd.DataFrame(grid_records)\n\n# Create axis lines (spokes from center to edge)\nspoke_records = []\nfor i, (cat, angle) in enumerate(zip(categories, angles, strict=True)):\n    spoke_records.append({\"category\": cat, \"x\": 0, \"y\": 0, \"order\": 0, \"spoke_id\": i})\n    spoke_records.append(\n        {\"category\": cat, \"x\": 105 * np.cos(angle), \"y\": 105 * np.sin(angle), \"order\": 1, \"spoke_id\": i}\n    )\nspoke_df = pd.DataFrame(spoke_records)\n\n# Create axis labels (positioned beyond the outer gridline)\nlabel_records = []\nfor cat, angle in zip(categories, angles, strict=True):\n    label_x = 125 * np.cos(angle)\n    label_y = 125 * np.sin(angle)\n    label_records.append({\"category\": cat, \"x\": label_x, \"y\": label_y})\nlabel_df = pd.DataFrame(label_records)\n\n# Create grid value labels on all spokes\nvalue_label_records = []\nfor r in [20, 40, 60, 80, 100]:\n    for angle in angles:\n        x = r * np.cos(angle) + 8\n        y = r * np.sin(angle) + 2\n        value_label_records.append({\"value\": str(r), \"x\": x, \"y\": y})\nvalue_label_df = pd.DataFrame(value_label_records)\n\n# Series list and colors\nseries_list = [\"Product A\", \"Product B\", \"Product C\"]\ncolor_scale = alt.Scale(domain=series_list, range=IMPRINT)\n\n# Domain for axes, sized to the hexagon's own geometry (not a generic square):\n# label radius 125 reaches the full radius only at the top/bottom vertices\n# (Price/Support); the left/right vertices (Quality/Durability/Features/\n# Design, at +-30 deg off horizontal) only reach 125*cos(30deg). Deriving\n# separate x/y half-ranges from that geometry (plus a fixed text buffer)\n# keeps the margin tight and the view free of the dead space a flat +-160\n# square domain would leave on the hexagon's shorter horizontal axis.\nLABEL_R = 125\nBUFFER = 18\nx_half = LABEL_R * np.cos(np.pi / 6) + BUFFER\ny_half = LABEL_R + BUFFER\naxis_domain_x = [-x_half, x_half]\naxis_domain_y = [-y_half, y_half]\n\n# Chart dimensions — square inner view (see prompts/library/altair.md \"Canvas\"),\n# aspect-matched to x_half:y_half so the hexagon renders undistorted.\nchart_width = 480\nchart_height = round(chart_width * y_half / x_half)\n\n# Base encoding for x and y\nx_enc = alt.X(\"x:Q\", scale=alt.Scale(domain=axis_domain_x), axis=None)\ny_enc = alt.Y(\"y:Q\", scale=alt.Scale(domain=axis_domain_y), axis=None)\n\n# Legend-bound selection: click a series to isolate it, click again to\n# release. A distinctly altair/vega-lite interaction — not reproducible in\n# a static PNG library — that shows up in the saved interactive HTML.\nlegend_selection = alt.selection_point(fields=[\"series\"], bind=\"legend\")\nfill_opacity = alt.condition(legend_selection, alt.value(0.25), alt.value(0.05))\nstroke_opacity = alt.condition(legend_selection, alt.value(0.9), alt.value(0.15))\npoint_opacity = alt.condition(legend_selection, alt.value(0.9), alt.value(0.15))\n\n# Grid hexagons\ngrid_lines = (\n    alt.Chart(grid_df)\n    .mark_line(strokeWidth=1.5, opacity=0.15)\n    .encode(x=x_enc, y=y_enc, detail=\"radius:N\", order=\"order:Q\", stroke=alt.value(INK_SOFT))\n)\n\n# Spokes (axis lines)\nspokes = (\n    alt.Chart(spoke_df)\n    .mark_line(strokeWidth=1.5, opacity=0.25)\n    .encode(x=x_enc, y=y_enc, detail=\"spoke_id:N\", order=\"order:Q\", stroke=alt.value(INK_SOFT))\n)\n\n# Axis labels\nlabels = (\n    alt.Chart(label_df)\n    .mark_text(fontSize=13, fontWeight=\"bold\")\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"category:N\", color=alt.value(INK))\n)\n\n# Grid value labels\nvalue_labels = (\n    alt.Chart(value_label_df)\n    .mark_text(fontSize=10, align=\"left\", baseline=\"middle\")\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"value:N\", color=alt.value(INK_SOFT))\n)\n\n# Create filled polygons for each series. `mark_area()` fills toward an\n# implicit baseline (it is designed for y=f(x) functions), so feeding it a\n# closed, non-monotonic radar-polygon path produces spurious fill spikes. A\n# `mark_line` with `interpolate=\"linear-closed\"` instead closes the path as\n# a true polygon and fills it directly -- the standard Vega-Lite technique\n# for radar/spider charts.\nfill_layers = []\nfor series_name, fill_color in zip(series_list, IMPRINT, strict=True):\n    series_df = df[df[\"series\"] == series_name].copy()\n\n    fill_layer = (\n        alt.Chart(series_df)\n        .mark_line(interpolate=\"linear-closed\", fill=fill_color, fillOpacity=0.25, strokeWidth=0)\n        .encode(x=x_enc, y=y_enc, opacity=fill_opacity, order=\"order:Q\")\n    )\n    fill_layers.append(fill_layer)\n\n# Polygon outlines\npolygon_outline = (\n    alt.Chart(df)\n    .mark_line(strokeWidth=3.5)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        color=alt.Color(\n            \"series:N\",\n            scale=color_scale,\n            legend=alt.Legend(\n                title=\"Series\",\n                titleFontSize=10,\n                labelFontSize=10,\n                orient=\"right\",\n                offset=10,\n                symbolSize=120,\n                symbolStrokeWidth=2,\n                fillColor=ELEVATED_BG,\n                strokeColor=INK_SOFT,\n                labelColor=INK_SOFT,\n                titleColor=INK,\n            ),\n        ),\n        opacity=stroke_opacity,\n        detail=\"series:N\",\n        order=\"order:Q\",\n    )\n)\n\n# Data points (exclude the closing point)\npoints_df = df[df[\"order\"] < n_categories].copy()\npoints = (\n    alt.Chart(points_df)\n    .mark_circle(size=160)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        color=alt.Color(\"series:N\", scale=color_scale, legend=None),\n        opacity=point_opacity,\n        tooltip=[\"series:N\", \"category:N\", \"value:Q\"],\n    )\n)\n\n# Combine all layers\nall_layers = [grid_lines, spokes] + fill_layers + [polygon_outline, points, labels, value_labels]\n\ntitle_text = \"radar-multi · python · altair · anyplot.ai\"\n\nchart = (\n    alt.layer(*all_layers)\n    .add_params(legend_selection)\n    .properties(\n        width=chart_width,\n        height=chart_height,\n        background=PAGE_BG,\n        title=alt.Title(title_text, fontSize=16, anchor=\"middle\", offset=20, color=INK),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_legend(strokeColor=INK_SOFT, padding=15, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save as PNG and HTML with theme suffix\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# PAD-only to the exact canonical target — never crop (see\n# prompts/library/altair.md \"Canvas — hard rule, no deviation\").\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}x{_h}, exceeds target {TW}x{TH}. Shrink chart dims 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\nchart.save(f\"plot-{THEME}.html\")\n"}