{"spec_id":"spiral-timeseries","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nspiral-timeseries: Spiral Time Series Chart\nLibrary: plotly 6.9.0 | Python 3.13.15\nQuality: 89/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport plotly.graph_objects as go\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\"\nGRID = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\n\n# Continuous data: single-polarity Imprint sequential scale (brand green -> blue)\nimprint_seq = [[0.0, \"#009E73\"], [1.0, \"#4467A3\"]]\n\n# Data: Daily average temperatures 2019–2023 (temperate northern hemisphere)\nnp.random.seed(42)\ndates = pd.date_range(\"2019-01-01\", \"2023-12-31\", freq=\"D\")\nn = len(dates)\n\ndoy = dates.day_of_year.values.astype(float)\nyr_num = (dates.year - 2019).values.astype(float)\n\nseasonal = -12.0 * np.cos(2 * np.pi * doy / 365.25)  # cold Jan, warm Jul\ntrend = yr_num * 0.4  # gradual multi-year warming signal\nnoise = np.random.normal(0, 1.8, n)\ntemperature = 10.0 + seasonal + trend + noise  # °C, roughly −4 to +26\n\nTMIN, TMAX = -5.0, 28.0\n\n# Archimedean spiral geometry (clockwise from top = Jan 1)\nSPACING = 2.0\nR0 = 1.2\nfrac = (doy - 1.0) / 365.25\ntheta = np.pi / 2.0 - 2 * np.pi * frac\nr = R0 + (yr_num + frac) * SPACING\nx_sp = r * np.cos(theta)\ny_sp = r * np.sin(theta)\n\nr_outer = R0 + 5.0 * SPACING  # outer boundary (end of 2023)\nr_label = r_outer + 0.55  # month-label ring\n\n\ndef lerp_color(t, c0=(0, 158, 115), c1=(68, 103, 163)):\n    \"\"\"Linear interpolation along the two-stop imprint_seq scale.\"\"\"\n    t = min(max(t, 0.0), 1.0)\n    rgb = [round(a + (b - a) * t) for a, b in zip(c0, c1)]\n    return f\"rgb({rgb[0]},{rgb[1]},{rgb[2]})\"\n\n\n# Smoothed color channel (15-day rolling mean) removes daily-noise flicker so\n# the ribbon reads as a polished gradient; raw daily values stay in hover text.\nNBINS = 40\nsmooth_temp = pd.Series(temperature).rolling(15, center=True, min_periods=1).mean().values\nbin_idx = np.clip(((smooth_temp - TMIN) / (TMAX - TMIN) * NBINS).astype(int), 0, NBINS - 1)\nrun_breaks = np.flatnonzero(np.diff(bin_idx) != 0)\nrun_starts = np.concatenate(([0], run_breaks + 1))\n# each run's end coincides with the next run's start so adjacent line traces\n# share a literal vertex — otherwise separately-capped traces leave visible\n# notches at every color transition\nrun_ends = np.concatenate((run_breaks + 1, [n - 1]))\n\n# Figure\nfig = go.Figure()\n\n# 1. Circular ring guides at each year boundary\narc_t = np.linspace(0, 2 * np.pi, 361)\nfor k in range(6):\n    arc_r = R0 + k * SPACING\n    fig.add_trace(\n        go.Scatter(\n            x=arc_r * np.cos(arc_t),\n            y=arc_r * np.sin(arc_t),\n            mode=\"lines\",\n            line=dict(color=GRID, width=1),\n            hoverinfo=\"skip\",\n            showlegend=False,\n        )\n    )\n\n# 2. Radial month dividers and labels\nmonth_names = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\nfor m, mname in enumerate(month_names):\n    mt = np.pi / 2.0 - 2 * np.pi * (m / 12.0)\n    fig.add_trace(\n        go.Scatter(\n            x=[0, (r_outer + 0.1) * np.cos(mt)],\n            y=[0, (r_outer + 0.1) * np.sin(mt)],\n            mode=\"lines\",\n            line=dict(color=GRID, width=1),\n            hoverinfo=\"skip\",\n            showlegend=False,\n        )\n    )\n    fig.add_annotation(\n        x=r_label * np.cos(mt),\n        y=r_label * np.sin(mt),\n        text=mname,\n        showarrow=False,\n        font=dict(size=12, color=INK, family=\"Arial, sans-serif\"),\n        xanchor=\"center\",\n        yanchor=\"middle\",\n    )\n\n# 3. Temperature-colored ribbon: one smooth line per constant-color run, so the\n# spiral reads as a continuous gradient stroke rather than a beaded scatter.\nfor start, end in zip(run_starts, run_ends):\n    t = (bin_idx[start] + 0.5) / NBINS\n    fig.add_trace(\n        go.Scatter(\n            x=x_sp[start : end + 1],\n            y=y_sp[start : end + 1],\n            mode=\"lines\",\n            line=dict(color=lerp_color(t), width=6, shape=\"linear\"),\n            hoverinfo=\"skip\",\n            showlegend=False,\n        )\n    )\n\n# 4. Invisible per-day markers carry the rich hover tooltips (raw, unsmoothed\n# temperature) without reintroducing a visible dotted texture.\nfig.add_trace(\n    go.Scatter(\n        x=x_sp,\n        y=y_sp,\n        mode=\"markers\",\n        marker=dict(size=8, color=\"rgba(0,0,0,0)\"),\n        text=[f\"{d.strftime('%b %d, %Y')}: {t:.1f}°C\" for d, t in zip(dates, temperature)],\n        hovertemplate=\"%{text}<extra></extra>\",\n        showlegend=False,\n    )\n)\n\n# 4b. Zero-size dummy trace: draws the colorbar for the ribbon's temperature scale\nfig.add_trace(\n    go.Scatter(\n        x=[None],\n        y=[None],\n        mode=\"markers\",\n        marker=dict(\n            size=0.1,\n            color=[TMIN, TMAX],\n            colorscale=imprint_seq,\n            cmin=TMIN,\n            cmax=TMAX,\n            showscale=True,\n            colorbar=dict(\n                title=dict(text=\"Temp (°C)\", font=dict(size=12, color=INK)),\n                tickfont=dict(size=10, color=INK_SOFT),\n                tickvals=[-5, 0, 5, 10, 15, 20, 25],\n                bgcolor=ELEVATED_BG,\n                bordercolor=INK_SOFT,\n                borderwidth=1,\n                thickness=14,\n                len=0.65,\n                x=1.03,\n            ),\n        ),\n        hoverinfo=\"skip\",\n        showlegend=False,\n    )\n)\n\n# 5. Year labels at Jan 1 position (top of each ring), annotated with each\n# year's average temperature to surface the multi-year warming trend alongside\n# the seasonal pattern.\nyearly_avg = pd.Series(temperature).groupby(dates.year).mean()\nfor k, yr in enumerate(range(2019, 2024)):\n    yr_r = R0 + k * SPACING + 0.12\n    fig.add_annotation(\n        x=0.0,\n        y=yr_r,\n        text=f\"<b>{yr}</b> · {yearly_avg[yr]:.1f}°C\",\n        showarrow=False,\n        font=dict(size=10, color=INK_MUTED, family=\"Arial, sans-serif\"),\n        xanchor=\"center\",\n        yanchor=\"bottom\",\n        bgcolor=PAGE_BG,\n        borderpad=3,\n    )\n\n# 6. Compact multi-year trend callout (below title): makes the secondary\n# year-over-year warming signal explicit, not just implied by the ring labels.\nwarming = yearly_avg[2023] - yearly_avg[2019]\nfig.add_annotation(\n    x=0.5,\n    y=0.955,\n    xref=\"paper\",\n    yref=\"paper\",\n    text=f\"2019 → 2023 avg: {yearly_avg[2019]:.1f}°C → {yearly_avg[2023]:.1f}°C (+{warming:.1f}°C)\",\n    showarrow=False,\n    font=dict(size=11, color=INK_SOFT, family=\"Arial, sans-serif\"),\n    xanchor=\"center\",\n    yanchor=\"top\",\n    bgcolor=PAGE_BG,\n    borderpad=4,\n)\n\n# Layout (square canvas for circular chart)\nax_lim = r_label + 0.5\nfig.update_layout(\n    autosize=False,\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    width=600,\n    height=600,\n    title=dict(\n        text=\"spiral-timeseries · python · plotly · anyplot.ai\",\n        font=dict(size=16, color=INK, family=\"Arial, sans-serif\"),\n        x=0.5,\n        xanchor=\"center\",\n        y=0.99,\n        yanchor=\"top\",\n    ),\n    xaxis=dict(range=[-ax_lim, ax_lim], scaleanchor=\"y\", scaleratio=1, visible=False),\n    yaxis=dict(range=[-ax_lim, ax_lim], visible=False),\n    margin=dict(l=10, r=60, t=85, b=10),\n    showlegend=False,\n)\n\n# Save — 600x600 @ scale=4 -> 2400x2400 (canonical square target)\nfig.write_image(f\"plot-{THEME}.png\", width=600, height=600, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}