{"spec_id":"spiral-timeseries","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nspiral-timeseries: Spiral Time Series Chart\nLibrary: bokeh 3.9.2 | Python 3.13.15\nQuality: 96/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Remove current directory from sys.path to avoid shadowing bokeh module\n_impl_dir = str(Path(__file__).parent.resolve())\nfor _p in (\"\", \".\", _impl_dir):\n    while _p in sys.path:\n        sys.path.remove(_p)\n\nimport numpy as np\nimport pandas as pd\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColorBar, ColumnDataSource, CustomJS, HoverTool, Label, LinearColorMapper\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme\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\n\n# Imprint sequential colormap (brand green -> blue) for continuous, single-polarity\n# (magnitude) data — temperature here is an intensity value, not a signed deviation,\n# so imprint_seq is the correct choice over imprint_div.\ndef _lerp_hex(c0, c1, t):\n    r0, g0, b0 = (int(c0[i : i + 2], 16) for i in (1, 3, 5))\n    r1, g1, b1 = (int(c1[i : i + 2], 16) for i in (1, 3, 5))\n    r, g, b = (int(round(a + (b - a) * t)) for a, b in ((r0, r1), (g0, g1), (b0, b1)))\n    return f\"#{r:02X}{g:02X}{b:02X}\"\n\n\nIMPRINT_SEQ256 = [_lerp_hex(\"#009E73\", \"#4467A3\", t / 255.0) for t in range(256)]\n\n# Data — daily average temperatures (synthetic) for a temperate city, 2019–2023\nnp.random.seed(42)\ndates = pd.date_range(\"2019-01-01\", \"2023-12-31\", freq=\"D\")\nn = len(dates)\nday_of_year = dates.day_of_year.values.astype(float)\nyear_offset = (dates.year.values - 2019).astype(float)\n\n# Seasonal sinusoidal pattern with slight warming trend and noise\ntemperature = (\n    12.0 + 14.0 * np.sin(2 * np.pi * (day_of_year - 80) / 365) + 0.3 * year_offset + np.random.normal(0, 2.5, n)\n)\n\n# Per-year mean temperature — surfaces the multi-year warming trend as a number\n# alongside each year label, rather than leaving it to be inferred from color alone.\nyear_avg_temp = [float(temperature[dates.year == yr].mean()) for yr in range(2019, 2024)]\nwarming_delta = year_avg_temp[-1] - year_avg_temp[0]\n\n# Archimedean spiral: r grows linearly with θ; one year ≈ one full revolution\ndays_elapsed = (dates - dates[0]).days.values.astype(float)\nnum_rev = 5.0\ntheta = 2 * np.pi * days_elapsed / 365.25  # continuous accumulated angle\n\ninner_r = 150.0\nouter_r = 640.0\nr = inner_r + (outer_r - inner_r) * theta / (num_rev * 2 * np.pi)\n\n# Cartesian coordinates — start at 12 o'clock (top), advance clockwise\nphi0 = np.pi / 2\nx = r * np.cos(phi0 - theta)\ny = r * np.sin(phi0 - theta)\n\n# Segment endpoints + midpoint temperatures for color mapping\nx0, y0, x1, y1 = x[:-1], y[:-1], x[1:], y[1:]\nseg_temp = (temperature[:-1] + temperature[1:]) / 2\ndate_strs = dates[:-1].strftime(\"%Y-%m-%d\").tolist()\nsource = ColumnDataSource({\"x0\": x0, \"y0\": y0, \"x1\": x1, \"y1\": y1, \"temp\": seg_temp, \"date\": date_strs})\n\n# Color mapper (Imprint sequential ramp for continuous temperature values)\nt_min, t_max = float(temperature.min()), float(temperature.max())\nmapper = LinearColorMapper(palette=IMPRINT_SEQ256, low=t_min, high=t_max)\n\n# Figure\np = figure(\n    width=2400,\n    height=2400,\n    title=\"spiral-timeseries · python · bokeh · anyplot.ai\",\n    toolbar_location=None,\n    x_range=(-820, 820),\n    y_range=(-820, 820),\n)\n\n# Month radial dividers and labels (one per month, at fixed angular positions)\nmonth_names = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n# Days elapsed since Jan 1 for each month's start (0-indexed, non-leap)\nmonth_day_offsets = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]\n\nfor doy_offset, mname in zip(month_day_offsets, month_names, strict=True):\n    ang = phi0 - 2 * np.pi * doy_offset / 365.25\n    r_inner_line = inner_r * 0.82\n    r_outer_line = outer_r * 1.07\n    p.line(\n        [r_inner_line * np.cos(ang), r_outer_line * np.cos(ang)],\n        [r_inner_line * np.sin(ang), r_outer_line * np.sin(ang)],\n        line_color=INK_SOFT,\n        line_alpha=0.25,\n        line_width=2,\n        line_dash=\"dashed\",\n    )\n    label_r = outer_r * 1.17\n    p.add_layout(\n        Label(\n            x=label_r * np.cos(ang),\n            y=label_r * np.sin(ang),\n            text=mname,\n            text_align=\"center\",\n            text_baseline=\"middle\",\n            text_color=INK_MUTED,\n            text_font_size=\"32pt\",\n        )\n    )\n\n# Outer boundary ring — annotates the final revolution's edge more prominently\np.circle(x=0, y=0, radius=outer_r * 1.02, fill_color=None, line_color=INK_SOFT, line_alpha=0.35, line_width=2.5)\n\n# Year labels — right-anchored just left of the Jan 1 divider (x=0), vertically\n# centered on each revolution's starting radius for a cleaner, less cramped read\n# than the previous fixed x=65 offset.\nfor yi in range(5):\n    yr_r = inner_r + (outer_r - inner_r) * yi / num_rev\n    p.add_layout(\n        Label(\n            x=-22,\n            y=yr_r,\n            text=str(2019 + yi),\n            text_align=\"right\",\n            text_baseline=\"middle\",\n            text_color=INK,\n            text_font_size=\"36pt\",\n            text_font_style=\"bold\",\n        )\n    )\n\n# Trend callout — the spiral's empty center hole (r < inner_r) is otherwise\n# unused, so it becomes a compact summary of the multi-year warming trend.\n# This gives the reader an explicit number for the trend the color ramp only\n# implies, without disturbing the month grid or year-label ring.\np.add_layout(\n    Label(\n        x=0,\n        y=16,\n        text=f\"+{warming_delta:.1f}°C\",\n        text_align=\"center\",\n        text_baseline=\"bottom\",\n        text_color=INK,\n        text_font_size=\"34pt\",\n        text_font_style=\"bold\",\n    )\n)\np.add_layout(\n    Label(\n        x=0,\n        y=8,\n        text=\"warming, 2019 → 2023\",\n        text_align=\"center\",\n        text_baseline=\"top\",\n        text_color=INK_MUTED,\n        text_font_size=\"18pt\",\n    )\n)\n\n# Spiral segments colored by temperature\nseg_renderer = p.segment(\n    x0=\"x0\", y0=\"y0\", x1=\"x1\", y1=\"y1\", line_color={\"field\": \"temp\", \"transform\": mapper}, line_width=9, source=source\n)\n\n# Hover halo — a CustomJS-driven bokeh-native touch: an initially empty glyph\n# source that the HoverTool's JS callback repositions onto the hovered segment,\n# ringing it in the page's ink color. More distinctive than a bare tooltip since\n# it reinforces exactly which point on the spiral the tooltip refers to.\nhalo_source = ColumnDataSource({\"x\": [], \"y\": []})\np.scatter(\n    x=\"x\",\n    y=\"y\",\n    source=halo_source,\n    marker=\"circle\",\n    size=22,\n    fill_color=None,\n    line_color=INK,\n    line_width=3,\n    line_alpha=0.9,\n)\n\nhover = HoverTool(\n    renderers=[seg_renderer],\n    tooltips=[(\"Date\", \"@date\"), (\"Temperature\", \"@temp{0.1f} °C\")],\n    line_policy=\"interp\",\n    callback=CustomJS(\n        args={\"seg_source\": source, \"halo_source\": halo_source},\n        code=\"\"\"\n        const indices = cb_data.index.indices\n        if (indices.length > 0) {\n            const i = indices[0]\n            halo_source.data = {x: [seg_source.data['x1'][i]], y: [seg_source.data['y1'][i]]}\n        } else {\n            halo_source.data = {x: [], y: []}\n        }\n        halo_source.change.emit()\n        \"\"\",\n    ),\n)\np.add_tools(hover)\n\n# Color bar — larger than the previous regen (40x600 on a 3600px canvas read as\n# tiny); at 50x650 against the new 2400px canvas it reads as a clear, legible key.\ncolor_bar = ColorBar(\n    color_mapper=mapper,\n    title=\"Temperature (°C)\",\n    title_text_font_size=\"30pt\",\n    title_text_color=INK_SOFT,\n    major_label_text_font_size=\"26pt\",\n    major_label_text_color=INK_SOFT,\n    background_fill_color=ELEVATED_BG,\n    bar_line_color=INK_SOFT,\n    width=50,\n    height=650,\n    label_standoff=14,\n)\np.add_layout(color_bar, \"right\")\n\n# Theme chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\np.title.text_color = INK\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"normal\"\np.title.align = \"center\"\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = None\n\n# Save HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome via Selenium\nW, H = 2400, 2400\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# Pin the viewport exactly via CDP — headless Chrome's --window-size sets the\n# OUTER window and still reserves a phantom title-bar height, so innerHeight\n# (and thus the screenshot) would otherwise come out short of H.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}