{"spec_id":"polar-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\npolar-basic: Basic Polar Chart\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 89/100 | Updated: 2026-07-25\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nBRAND = \"#009E73\"\n\n# Data - Hourly temperature pattern (24-hour cycle)\nnp.random.seed(42)\nhours = np.arange(24)\n\nbase_temp = 15 + 10 * np.sin((hours - 9) * np.pi / 12)\ntemperatures = base_temp + np.random.randn(24) * 1.5\n\ntheta = (90 - hours * 15) * np.pi / 180\nradius = temperatures - temperatures.min() + 5\n\nx = radius * np.cos(theta)\ny = radius * np.sin(theta)\n\ndf = pd.DataFrame({\"hour\": hours, \"temperature\": temperatures, \"x\": x, \"y\": y})\n\n# Radial gridlines (concentric circles)\nmax_radius = radius.max() + 2\ngrid_radii = np.linspace(5, max_radius, 5)\ncircle_angles = np.linspace(0, 2 * np.pi, 101)\n\ngrid_rows = []\nfor i, r in enumerate(grid_radii):\n    for j, angle in enumerate(circle_angles):\n        grid_rows.append({\"x\": r * np.cos(angle), \"y\": r * np.sin(angle), \"circle_id\": i, \"order\": j})\n\ngrid_df = pd.DataFrame(grid_rows)\n\n# Angular gridlines (spokes at major hours)\nspoke_data = []\nmajor_hours = [0, 3, 6, 9, 12, 15, 18, 21]\nfor hour in major_hours:\n    angle = (90 - hour * 15) * np.pi / 180\n    spoke_data.append({\"x\": 0, \"y\": 0, \"xend\": max_radius * np.cos(angle), \"yend\": max_radius * np.sin(angle)})\n\nspoke_df = pd.DataFrame(spoke_data)\n\n# Hour labels around perimeter\nlabel_data = []\nhour_labels_map = {0: \"00:00\", 3: \"03:00\", 6: \"06:00\", 9: \"09:00\", 12: \"12:00\", 15: \"15:00\", 18: \"18:00\", 21: \"21:00\"}\nlabel_radius = max_radius + 4\nfor hour, label in hour_labels_map.items():\n    angle = (90 - hour * 15) * np.pi / 180\n    label_data.append({\"label\": label, \"x\": label_radius * np.cos(angle), \"y\": label_radius * np.sin(angle)})\n\nlabel_df = pd.DataFrame(label_data)\n\n# Radial scale-reference labels — 2 of the 5 concentric rings, each placed at an\n# angle where the data path sits far from that ring (avoids crossing the line).\nring_label_specs = [(1, -80), (3, 80)]\nring_label_data = []\nfor idx, angle_deg in ring_label_specs:\n    r = grid_radii[idx]\n    temp_value = r - 5 + temperatures.min()\n    angle = np.deg2rad(angle_deg)\n    ring_label_data.append({\"label\": f\"{temp_value:.0f}°C\", \"x\": r * np.cos(angle), \"y\": r * np.sin(angle)})\n\nring_label_df = pd.DataFrame(ring_label_data)\n\n# Closed path for the data line\ndf_sorted = df.sort_values(\"hour\").copy()\ndf_sorted[\"order\"] = df_sorted[\"hour\"]\nfirst_row = df_sorted.iloc[[0]].copy()\nfirst_row[\"order\"] = 24\ndf_path = pd.concat([df_sorted, first_row], ignore_index=True)\n\n# Shared coordinate scale — the square inner view (500x460) is not literally\n# square, so the x-domain is widened by the width/height ratio to keep the\n# polar gridlines circular instead of squashed into ellipses.\nVIEW_W, VIEW_H = 500, 460\nouter_extent = label_radius + 3\naspect = VIEW_W / VIEW_H\nX_SCALE = alt.Scale(domain=[-outer_extent * aspect, outer_extent * aspect])\nY_SCALE = alt.Scale(domain=[-outer_extent, outer_extent])\n\n# Hover selection — highlights the nearest hour's point/tooltip in the HTML export\nhover = alt.selection_point(on=\"pointerover\", nearest=True, fields=[\"hour\"], empty=False)\n\n# Plot\nGRID_OPACITY = 0.25\n\ncircles = (\n    alt.Chart(grid_df)\n    .mark_line(strokeWidth=1.2, opacity=GRID_OPACITY, color=INK_SOFT, strokeDash=[4, 4])\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=X_SCALE),\n        y=alt.Y(\"y:Q\", axis=None, scale=Y_SCALE),\n        detail=\"circle_id:N\",\n        order=\"order:O\",\n    )\n)\n\nspokes = (\n    alt.Chart(spoke_df)\n    .mark_rule(strokeWidth=1.2, opacity=GRID_OPACITY, color=INK_SOFT)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=X_SCALE), y=alt.Y(\"y:Q\", axis=None, scale=Y_SCALE), x2=\"xend:Q\", y2=\"yend:Q\"\n    )\n)\n\nlabels = (\n    alt.Chart(label_df)\n    .mark_text(fontSize=15, fontWeight=\"bold\", color=INK)\n    .encode(x=alt.X(\"x:Q\", axis=None, scale=X_SCALE), y=alt.Y(\"y:Q\", axis=None, scale=Y_SCALE), text=\"label:N\")\n)\n\nring_labels = (\n    alt.Chart(ring_label_df)\n    .mark_text(fontSize=11, fontStyle=\"italic\", color=INK_MUTED)\n    .encode(x=alt.X(\"x:Q\", axis=None, scale=X_SCALE), y=alt.Y(\"y:Q\", axis=None, scale=Y_SCALE), text=\"label:N\")\n)\n\nline = (\n    alt.Chart(df_path)\n    .mark_line(strokeWidth=3.5, color=BRAND, opacity=0.85)\n    .encode(x=alt.X(\"x:Q\", axis=None, scale=X_SCALE), y=alt.Y(\"y:Q\", axis=None, scale=Y_SCALE), order=\"order:O\")\n)\n\npoints = (\n    alt.Chart(df)\n    .mark_point(filled=True, size=200, opacity=0.95, stroke=PAGE_BG, strokeWidth=1.2)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=X_SCALE),\n        y=alt.Y(\"y:Q\", axis=None, scale=Y_SCALE),\n        color=alt.Color(\n            \"temperature:Q\",\n            scale=alt.Scale(range=[BRAND, \"#4467A3\"]),  # imprint_seq: single-polarity continuous\n            legend=alt.Legend(title=\"Temp (°C)\"),\n        ),\n        size=alt.condition(hover, alt.value(400), alt.value(200)),\n        tooltip=[\n            alt.Tooltip(\"hour:O\", title=\"Hour\"),\n            alt.Tooltip(\"temperature:Q\", title=\"Temperature (°C)\", format=\".1f\"),\n        ],\n    )\n    .add_params(hover)\n)\n\nchart = (\n    alt.layer(circles, spokes, line, points, labels, ring_labels)\n    .properties(\n        background=PAGE_BG,\n        width=VIEW_W,\n        height=VIEW_H,\n        title=alt.Title(text=\"polar-basic · python · altair · anyplot.ai\", fontSize=18, anchor=\"middle\"),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_title(color=INK)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=11,\n        titleFontSize=10,\n        titleLimit=200,\n        gradientThickness=20,\n        padding=6,\n    )\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Pad-only to the canonical 2400x2400 square — vl-convert pads the view with\n# title/legend extents outside width/height, so the raw save rarely lands\n# exactly on target. Never crop: that would clip title/label text at the edges.\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}. \"\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\nchart.save(f\"plot-{THEME}.html\")\n"}