{"spec_id":"spiral-timeseries","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nspiral-timeseries: Spiral Time Series Chart\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 87/100 | Updated: 2026-08-18\n\"\"\"\n\nimport datetime\nimport importlib\nimport itertools\nimport math\nimport os\nimport sys\nimport xml.etree.ElementTree as ET\n\n\n# Remove the script's own directory from sys.path so importlib resolves\n# \"pygal\" to the installed package, not this file (same package name).\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nnp = importlib.import_module(\"numpy\")\npygal = importlib.import_module(\"pygal\")\nStyle = importlib.import_module(\"pygal.style\").Style\ncairosvg = importlib.import_module(\"cairosvg\")\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\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# Data — daily average temperatures over 5 years (Northern Hemisphere city)\nnp.random.seed(42)\nn_years = 5\ndays_per_year = 365\nn_points = n_years * days_per_year\n\nday_indices = np.arange(n_points)\nyear_idx = day_indices // days_per_year\nday_of_year = day_indices % days_per_year\n\nannual_mean = 12.0  # °C\namplitude = 13.0  # °C seasonal amplitude\ntemp = (\n    annual_mean\n    + amplitude * np.cos(2 * np.pi * day_of_year / days_per_year + np.pi)\n    + np.random.normal(0, 2.5, n_points)\n    + year_idx * 0.4  # subtle multi-year warming trend\n)\n\n# Archimedean spiral geometry\nbase_r = 3.0\nrev_gap = 2.5\ntemp_scale = 0.5  # radial deviation amplitude\nt_norm = (temp - annual_mean) / (amplitude + 3.0)\n\ntheta = 2 * np.pi * day_of_year / days_per_year - math.pi / 2\nradius = base_r + year_idx * rev_gap + temp_scale * t_norm\nx_coords = (radius * np.cos(theta)).tolist()\ny_coords = (radius * np.sin(theta)).tolist()\n\n# Imprint sequential colormap (brand green -> blue) for temperature buckets.\nT_MIN = float(np.percentile(temp, 1))\nT_MAX = float(np.percentile(temp, 99))\nN_BUCKETS = 14\n\n\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 = (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\ndef _rolling_mean(values, window):\n    half = window // 2\n    n = len(values)\n    return np.array([values[max(0, i - half) : min(n, i + half + 1)].mean() for i in range(n)])\n\n\nbucket_colors = tuple(_lerp_hex(\"#009E73\", \"#4467A3\", i / (N_BUCKETS - 1)) for i in range(N_BUCKETS))\nbucket_edges = np.linspace(T_MIN, T_MAX, N_BUCKETS + 1)\n\n# pygal draws a straight line between any two points added to a series\n# regardless of how far apart they are in time, so bucketing on the raw\n# noisy daily value would connect unrelated days that happen to share a\n# temperature (e.g. a cold spring day with a cold autumn day) into long\n# spurious chords. Bucketing on an 11-day rolling mean instead gives each\n# color a smooth, slow-changing assignment, so consecutive days sharing a\n# bucket are genuinely adjacent in time — grouped below into one contiguous\n# arc per run via itertools.groupby.\nsmoothed_temp = _rolling_mean(temp, 11)\nbucket_idx = np.clip(np.digitize(smoothed_temp, bucket_edges[1:]), 0, N_BUCKETS - 1)\n\nbase_date = datetime.date(2019, 1, 1)\narc_runs = []  # list of (color, points) — one per contiguous same-bucket run\nfor y in range(n_years):\n    year_buckets = bucket_idx[y * days_per_year : (y + 1) * days_per_year]\n    for b, days in itertools.groupby(range(days_per_year), key=lambda d: year_buckets[d]):\n        points = []\n        for d in days:\n            i = y * days_per_year + d\n            date_str = (base_date + datetime.timedelta(days=i)).strftime(\"%b %d, %Y\")\n            points.append({\"value\": (x_coords[i], y_coords[i]), \"label\": f\"{date_str}: {temp[i]:.1f}°C\"})\n        arc_runs.append((bucket_colors[b], points))\n\n# Spoke geometry — kept as real chart series (not just an overlay) so pygal's\n# own auto-scaling accounts for their reach; the legend is hidden entirely\n# (custom colorbar below replaces it), so the \"Month grid\" name is never shown.\nouter_r = base_r + (n_years - 1) * rev_gap + temp_scale + 1.5  # ~15.0\nlabel_r = outer_r + 1.2  # beyond outer ring, for month text anchors\n\nmonth_names = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n\nspoke_points = []\nfor m in range(12):\n    th = 2 * math.pi * m / 12 - math.pi / 2\n    spoke_points.append({\"value\": (0.0, 0.0)})\n    spoke_points.append({\"value\": (outer_r * math.cos(th), outer_r * math.sin(th))})\n    if m < 11:\n        spoke_points.append(None)\n\nstart_year = 2019\n\n# pygal Style — one color slot per contiguous run (in add() order), plus one\n# for the spoke series. Legend is hidden; a custom SVG colorbar below\n# communicates the temperature scale instead.\nrun_colors = tuple(color for color, _ in arc_runs)\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=run_colors + (INK_MUTED,),\n    title_font_size=66,\n    major_label_font_size=44,\n    stroke_width=2.6,\n)\n\nchart = pygal.XY(\n    style=custom_style,\n    width=2400,\n    height=2400,\n    title=\"spiral-timeseries · python · pygal · anyplot.ai\",\n    show_dots=False,\n    stroke=True,\n    show_x_labels=False,\n    show_y_labels=False,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_legend=False,\n)\n\n# Temperature-colored spiral arcs (Imprint sequential gradient) — one series\n# per contiguous same-bucket run so no line ever jumps between distant days.\nfor idx, (_, points) in enumerate(arc_runs):\n    chart.add(f\"run {idx}\", points)\n\n# Month guide spokes (data only — no legend entry since show_legend=False).\n# Thin dashed stroke keeps them a subtle background grid behind the data rings.\nchart.add(\"Month grid\", spoke_points, stroke_style={\"width\": 1, \"dasharray\": \"4,6\"})\n\n# --- SVG post-processing: inject permanent month/year labels + a colorbar ---\nsvg_bytes = chart.render()\nsvg_str = svg_bytes.decode(\"utf-8\")\n\n# Register SVG namespace to preserve structure\nET.register_namespace(\"\", \"http://www.w3.org/2000/svg\")\nET.register_namespace(\"xlink\", \"http://www.w3.org/1999/xlink\")\n\nroot = ET.fromstring(svg_str)\nsvg_w = float(root.get(\"width\", 2400))\nsvg_h = float(root.get(\"height\", 2400))\n\n# Estimate plot area from pygal's layout: title ~130px, bottom band reserved\n# for the custom colorbar ~200px, side margins ~70px.\nmargin_top = 130\nmargin_bottom = 200\nmargin_side = 70\nplot_w = svg_w - 2 * margin_side\nplot_h = svg_h - margin_top - margin_bottom\n\n# Data bounds — use the actual chart data range (spiral + spokes, not label anchors)\nall_x = x_coords + [outer_r * math.cos(2 * math.pi * m / 12 - math.pi / 2) for m in range(12)]\nall_y = y_coords + [outer_r * math.sin(2 * math.pi * m / 12 - math.pi / 2) for m in range(12)]\ndata_xmin, data_xmax = min(all_x), max(all_x)\ndata_ymin, data_ymax = min(all_y), max(all_y)\n# Add a small margin (pygal pads the axes)\npad = 0.05\ndx = (data_xmax - data_xmin) * pad\ndy = (data_ymax - data_ymin) * pad\ndata_xmin -= dx\ndata_xmax += dx\ndata_ymin -= dy\ndata_ymax += dy\n\n\ndef data_to_svg(dx_val, dy_val):\n    sx = margin_side + (dx_val - data_xmin) / (data_xmax - data_xmin) * plot_w\n    # SVG y-axis is inverted\n    sy = margin_top + (1 - (dy_val - data_ymin) / (data_ymax - data_ymin)) * plot_h\n    return sx, sy\n\n\n# Inject month labels as SVG <text> elements\nsvg_ns = \"http://www.w3.org/2000/svg\"\nfor m in range(12):\n    th = 2 * math.pi * m / 12 - math.pi / 2\n    mx = label_r * math.cos(th)\n    my = label_r * math.sin(th)\n    sx, sy = data_to_svg(mx, my)\n    text_el = ET.SubElement(root, f\"{{{svg_ns}}}text\")\n    text_el.set(\"x\", f\"{sx:.1f}\")\n    text_el.set(\"y\", f\"{sy:.1f}\")\n    text_el.set(\"text-anchor\", \"middle\")\n    text_el.set(\"dominant-baseline\", \"middle\")\n    text_el.set(\"font-size\", \"36\")\n    text_el.set(\"font-family\", \"sans-serif\")\n    text_el.set(\"fill\", INK_SOFT)\n    text_el.text = month_names[m]\n\n# Inject year-start labels at Jan 1 of each ring\nfor y in range(n_years):\n    th = -math.pi / 2  # Jan 1 = 12-o'clock\n    r = base_r + y * rev_gap\n    lx = r * math.cos(th) + 0.6  # slight rightward offset from vertical axis\n    ly = r * math.sin(th) - 0.2\n    sx, sy = data_to_svg(lx, ly)\n    text_el = ET.SubElement(root, f\"{{{svg_ns}}}text\")\n    text_el.set(\"x\", f\"{sx:.1f}\")\n    text_el.set(\"y\", f\"{sy:.1f}\")\n    text_el.set(\"text-anchor\", \"start\")\n    text_el.set(\"dominant-baseline\", \"middle\")\n    text_el.set(\"font-size\", \"36\")\n    text_el.set(\"font-family\", \"sans-serif\")\n    text_el.set(\"font-weight\", \"bold\")\n    text_el.set(\"fill\", INK)\n    text_el.text = str(start_year + y)\n\n# Custom colorbar (replaces the built-in legend) — narrative anchor for the\n# temperature scale, drawn as a smooth gradient strip with endpoint labels.\nBAR_SEGMENTS = 60\nbar_w = plot_w * 0.6\nbar_h = 40\nbar_x0 = margin_side + (plot_w - bar_w) / 2\nbar_y0 = svg_h - margin_bottom + 60\nseg_w = bar_w / BAR_SEGMENTS\nfor s in range(BAR_SEGMENTS):\n    seg_color = _lerp_hex(\"#009E73\", \"#4467A3\", s / (BAR_SEGMENTS - 1))\n    rect_el = ET.SubElement(root, f\"{{{svg_ns}}}rect\")\n    rect_el.set(\"x\", f\"{bar_x0 + s * seg_w:.1f}\")\n    rect_el.set(\"y\", f\"{bar_y0:.1f}\")\n    rect_el.set(\"width\", f\"{seg_w + 0.5:.1f}\")\n    rect_el.set(\"height\", f\"{bar_h}\")\n    rect_el.set(\"fill\", seg_color)\n\ncaption_el = ET.SubElement(root, f\"{{{svg_ns}}}text\")\ncaption_el.set(\"x\", f\"{svg_w / 2:.1f}\")\ncaption_el.set(\"y\", f\"{bar_y0 - 22:.1f}\")\ncaption_el.set(\"text-anchor\", \"middle\")\ncaption_el.set(\"font-size\", \"32\")\ncaption_el.set(\"font-family\", \"sans-serif\")\ncaption_el.set(\"fill\", INK_SOFT)\ncaption_el.text = \"Daily average temperature (°C)\"\n\nfor value, x_pos, anchor in ((T_MIN, bar_x0, \"start\"), (T_MAX, bar_x0 + bar_w, \"end\")):\n    label_el = ET.SubElement(root, f\"{{{svg_ns}}}text\")\n    label_el.set(\"x\", f\"{x_pos:.1f}\")\n    label_el.set(\"y\", f\"{bar_y0 + bar_h + 40:.1f}\")\n    label_el.set(\"text-anchor\", anchor)\n    label_el.set(\"font-size\", \"32\")\n    label_el.set(\"font-family\", \"sans-serif\")\n    label_el.set(\"fill\", INK)\n    label_el.text = f\"{value:.0f}°C\"\n\nmodified_svg = ET.tostring(root, encoding=\"unicode\", xml_declaration=False)\nmodified_svg_bytes = (\"<?xml version='1.0' encoding='utf-8'?>\\n\" + modified_svg).encode(\"utf-8\")\n\n# Save PNG and interactive HTML from the same annotated SVG (month/year labels,\n# colorbar) so both outputs stay in sync; per-day hover tooltips on the spiral\n# arcs still come from pygal's native interactivity.\ncairosvg.svg2png(bytestring=modified_svg_bytes, write_to=f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(modified_svg_bytes)\n"}