{"spec_id":"line-cycle-seasonal","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nline-cycle-seasonal: Cycle Plot (Seasonal Subseries)\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 85/100 | Created: 2026-06-15\n\"\"\"\n\nimport io\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Workaround: remove the script's own directory from sys.path so that the file\n# bokeh.py doesn't shadow the installed bokeh package on import.\noriginal_path = sys.path.copy()\nsys.path = [p for p in sys.path if p != \"\" and not (os.path.isfile(os.path.join(p, \"bokeh.py\")) if p else False)]\n\ntry:\n    import numpy as np\n    import pandas as pd\n    from bokeh.io import output_file, save\n    from bokeh.models import CustomJSTickFormatter, FixedTicker, Label\n    from bokeh.plotting import figure\n    from PIL import Image\n    from selenium import webdriver\n    from selenium.webdriver.chrome.options import Options\nfinally:\n    sys.path = original_path\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\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRAND = IMPRINT_PALETTE[0]  # #009E73 — annual subseries lines (first series)\nBLUE = IMPRINT_PALETTE[2]  # #4467A3 — seasonal mean reference lines\n\n# Data: synthetic monthly average temperature (°C) at a mid-latitude station, 2005–2023\nnp.random.seed(42)\nn_years = 19\nmonths = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\nn_months = 12\n\nseasonal_base = np.array([2.1, 3.4, 7.8, 13.2, 18.5, 22.8, 25.3, 24.9, 20.1, 14.0, 7.5, 3.2])\nwarming_rate = 0.05  # °C per year — long-term warming signal\n\nrows = []\nfor yi in range(n_years):\n    for mi in range(n_months):\n        temp = seasonal_base[mi] + warming_rate * yi + np.random.normal(0, 0.8)\n        rows.append({\"year_idx\": yi, \"month_idx\": mi, \"temp\": temp})\ndf = pd.DataFrame(rows)\n\n# Layout: 19 data points per group (year_idx 0..18); gap=5; step=24\n# → integer midpoints at 9, 33, 57, 81, ... for clean tick alignment\ngroup_width = n_years - 1  # 18 (positions 0 to 18)\ngroup_gap = 5\ntotal_step = group_width + group_gap + 1  # 24\n\ngroup_starts = [mi * total_step for mi in range(n_months)]\ngroup_ends = [gs + group_width for gs in group_starts]\ngroup_mids = [gs + group_width // 2 for gs in group_starts]  # 9, 33, 57, ...\ngroup_means = [df[df[\"month_idx\"] == mi][\"temp\"].mean() for mi in range(n_months)]\n\n# Multi-line data for subseries (annual trend within each month group)\nsub_xs = []\nsub_ys = []\nfor mi in range(n_months):\n    m_df = df[df[\"month_idx\"] == mi].sort_values(\"year_idx\")\n    sub_xs.append([mi * total_step + int(yi) for yi in m_df[\"year_idx\"]])\n    sub_ys.append(m_df[\"temp\"].tolist())\n\n# Multi-line data for mean reference lines (horizontal span per group)\nmean_xs = [[group_starts[mi], group_ends[mi]] for mi in range(n_months)]\nmean_ys = [[group_means[mi], group_means[mi]] for mi in range(n_months)]\n\n# Vertical divider positions (midpoint of each gap between groups)\ndiv_xs = [[group_ends[mi] + (group_gap + 1) / 2.0] * 2 for mi in range(n_months - 1)]\ndiv_ys = [[-3.5, 31.5] for _ in range(n_months - 1)]\n\n# Scatter markers — flattened positions for all annual data points\nall_x = [x for xs in sub_xs for x in xs]\nall_y = [y for ys in sub_ys for y in ys]\n\n# Linear trend per monthly group to show within-season warming signal\ntrend_xs = []\ntrend_ys = []\nfor mi in range(n_months):\n    m_x = np.array(sub_xs[mi], dtype=float)\n    m_y = np.array(sub_ys[mi], dtype=float)\n    coeffs = np.polyfit(m_x, m_y, 1)\n    trend_xs.append([float(m_x[0]), float(m_x[-1])])\n    trend_ys.append([float(coeffs[0] * m_x[0] + coeffs[1]), float(coeffs[0] * m_x[-1] + coeffs[1])])\n\n# Title — scale fontsize for long title\ntitle_str = \"Monthly Air Temperature · line-cycle-seasonal · python · bokeh · anyplot.ai\"\nn_chars = len(title_str)\ntitle_fontsize = max(34, round(50 * 67 / n_chars)) if n_chars > 67 else 50\n\n# Build figure\np = figure(\n    width=3200,\n    height=1800,\n    title=title_str,\n    x_axis_label=\"Month\",\n    y_axis_label=\"Temperature (°C)\",\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n    y_range=(-3.5, 31.5),\n)\n\n# Group dividers (drawn first, renders below data)\np.multi_line(div_xs, div_ys, line_color=INK_SOFT, line_alpha=0.38, line_width=1)\n\n# Seasonal mean reference lines (thick blue, drawn below annual lines)\np.multi_line(mean_xs, mean_ys, line_color=BLUE, line_width=5, line_alpha=0.9, legend_label=\"Monthly mean\")\n\n# Annual subseries lines (brand green, one per month group)\np.multi_line(sub_xs, sub_ys, line_color=BRAND, line_width=2.5, line_alpha=0.8, legend_label=\"Annual values\")\n\n# Scatter markers for individual annual data points\np.scatter(all_x, all_y, size=8, color=BRAND, line_color=PAGE_BG, line_width=0.5)\n\n# Dashed trend lines per group — make the within-season warming signal explicit\nAMBER = IMPRINT_PALETTE[3]  # #BD8233 — trend annotation distinct from data colors\np.multi_line(trend_xs, trend_ys, line_color=AMBER, line_width=2.0, line_alpha=0.65, line_dash=\"dashed\")\ncenter_x = (group_starts[0] + group_ends[-1]) / 2.0\nwarming_label = Label(\n    x=center_x,\n    y=29.5,\n    text=\"↑ warming trend: +0.05 °C/yr\",\n    text_font_size=\"22pt\",\n    text_color=AMBER,\n    text_align=\"center\",\n    text_alpha=0.85,\n    background_fill_alpha=0.0,\n    border_line_alpha=0.0,\n)\np.add_layout(warming_label)\n\n# Custom x-axis: ticks at integer group midpoints mapped to month names\njs_labels = \"{\" + \", \".join(f'\"{mid}\": \"{m}\"' for mid, m in zip(group_mids, months, strict=True)) + \"}\"\np.xaxis.ticker = FixedTicker(ticks=group_mids)\np.xaxis.formatter = CustomJSTickFormatter(\n    code=(f\"const labels = {js_labels}; return labels[String(Math.round(tick))] || '';\")\n)\np.xaxis.major_tick_line_color = None\np.xaxis.minor_tick_line_color = None\n\n# Grid\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.15\n\n# Remove full-box outline; axis lines provide the L-shaped spine\np.outline_line_color = None\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.yaxis.minor_tick_line_color = None\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\np.title.text_font_size = f\"{title_fontsize}pt\"\np.title.text_color = INK\np.title.text_font_style = \"bold\"\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\n\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\n# Legend\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = None\np.legend.label_text_color = INK_SOFT\np.legend.label_text_font_size = \"34pt\"\np.legend.location = \"top_right\"\n\n# Save interactive HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome (Selenium 4 / Selenium Manager).\n# Chrome's internal overhead shrinks the viewport below --window-size by ~139 px;\n# use H + 200 buffer, then crop to exact canvas dimensions with PIL.\nW, H = 3200, 1800\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 + 200}\",\n    \"--hide-scrollbars\",\n    \"--force-device-scale-factor=1\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H + 200)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\nraw = driver.get_screenshot_as_png()\ndriver.quit()\nImage.open(io.BytesIO(raw)).crop((0, 0, W, H)).save(f\"plot-{THEME}.png\")\n"}