{"spec_id":"line-cycle-seasonal","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nline-cycle-seasonal: Cycle Plot (Seasonal Subseries)\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 83/100 | Created: 2026-06-15\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Remove current directory from sys.path to avoid local altair.py shadowing the package\nscript_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if p != script_dir and os.path.abspath(p) != script_dir]\n\nimport altair as alt\n\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\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\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRAND = IMPRINT_PALETTE[0]  # always first series\n\n# Data: monthly average temperatures, 25 years (1999–2023)\nnp.random.seed(42)\nMONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\nN_MONTHS = 12\nN_YEARS = 25\nSTRIDE = N_YEARS + 3  # 28 positions per month group (25 data + 3 gap)\n\n# Northern-hemisphere seasonal baseline (°C) with a long-term warming trend\nseasonal_base = np.array([-3.0, -1.0, 4.0, 10.0, 16.0, 20.0, 22.0, 21.0, 16.0, 9.0, 3.0, -2.0])\n\nrecords = []\nfor y_idx in range(N_YEARS):\n    for m_idx, month in enumerate(MONTHS):\n        temp = seasonal_base[m_idx] + 0.06 * y_idx + np.random.normal(0, 1.2)\n        records.append(\n            {\n                \"month\": month,\n                \"month_idx\": m_idx,\n                \"year\": 1999 + y_idx,\n                \"year_idx\": y_idx,\n                \"temperature\": round(temp, 1),\n                \"x_pos\": m_idx * STRIDE + y_idx,\n            }\n        )\n\ndf = pd.DataFrame(records)\n\n# Monthly means for horizontal reference lines\nmeans = df.groupby([\"month\", \"month_idx\"])[\"temperature\"].mean().reset_index()\nmeans.columns = [\"month\", \"month_idx\", \"mean_temp\"]\nmeans[\"x_start\"] = means[\"month_idx\"] * STRIDE\nmeans[\"x_end\"] = means[\"month_idx\"] * STRIDE + N_YEARS - 1\n\n# Series labels for shared legend\ndf[\"series\"] = \"Yearly trend\"\nmeans[\"series\"] = \"Seasonal mean\"\nSERIES_SCALE = alt.Scale(domain=[\"Yearly trend\", \"Seasonal mean\"], range=[BRAND, IMPRINT_PALETTE[1]])\n\n# Vertical dividers between month groups (centred in each gap)\ndividers_df = pd.DataFrame({\"x\": [i * STRIDE + N_YEARS + 1 for i in range(N_MONTHS - 1)]})\n\n# Title with length-scaled fontsize (baseline default = 16px at 67 chars)\ntitle_text = \"Monthly Temperature Cycles · line-cycle-seasonal · python · altair · anyplot.ai\"\ntitle_fs = max(11, round(16 * 67 / len(title_text))) if len(title_text) > 67 else 16\n\n# X-axis: custom tick positions at the centre of each month group\nx_ticks = [i * STRIDE + (N_YEARS - 1) / 2.0 for i in range(N_MONTHS)]\nmonth_label_expr = (\n    f\"['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][floor(datum.value / {STRIDE})]\"\n)\n\n# Layer 1 — within-month chronological trend lines\nlines = (\n    alt.Chart(df)\n    .mark_line(strokeWidth=1.8, opacity=0.60)\n    .encode(\n        x=alt.X(\n            \"x_pos:Q\",\n            title=None,\n            axis=alt.Axis(values=x_ticks, labelExpr=month_label_expr, labelFontSize=10, gridOpacity=0, tickSize=6),\n        ),\n        y=alt.Y(\"temperature:Q\", title=\"Temperature (°C)\"),\n        detail=\"month:N\",\n        color=alt.Color(\"series:N\", scale=SERIES_SCALE, legend=alt.Legend(title=None)),\n    )\n)\n\n# Layer 2 — monthly mean reference lines (key seasonal comparison signal)\nmean_rules = (\n    alt.Chart(means)\n    .mark_rule(strokeWidth=3.2, opacity=0.9)\n    .encode(\n        x=\"x_start:Q\",\n        x2=\"x_end:Q\",\n        y=\"mean_temp:Q\",\n        color=alt.Color(\"series:N\", scale=SERIES_SCALE, legend=alt.Legend(title=None)),\n    )\n)\n\n# Layer 3 — subtle vertical dividers between month groups\ndividers = (\n    alt.Chart(dividers_df).mark_rule(color=INK_MUTED, strokeWidth=0.8, opacity=0.35, strokeDash=[3, 5]).encode(x=\"x:Q\")\n)\n\n# Compose and configure\nchart = (\n    (lines + mean_rules + dividers)\n    .properties(width=620, height=320, background=PAGE_BG, title=title_text)\n    .configure_view(fill=PAGE_BG, stroke=None)\n    .configure_title(fontSize=title_fs, color=INK, anchor=\"start\")\n    .configure_axis(\n        domainColor=INK_SOFT, tickColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK, gridColor=INK, gridOpacity=0.15\n    )\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save PNG + HTML (interactive)\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Pad PNG to exact canvas target (3200 × 1800)\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=) 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"}