{"spec_id":"streamgraph-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nstreamgraph-basic: Basic Stream Graph\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 91/100 | Updated: 2026-08-05\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove script directory from sys.path so 'import pygal' finds the installed package\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _script_dir]\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\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\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Prepend background color for the invisible baseline series, then Imprint for genres\nCOLORS = [PAGE_BG] + IMPRINT_PALETTE\n\n# Data: monthly streaming hours by music genre over two years\nnp.random.seed(42)\n\nmonths = 24\nmonth_labels = [\n    \"Jan 23\",\n    \"Feb 23\",\n    \"Mar 23\",\n    \"Apr 23\",\n    \"May 23\",\n    \"Jun 23\",\n    \"Jul 23\",\n    \"Aug 23\",\n    \"Sep 23\",\n    \"Oct 23\",\n    \"Nov 23\",\n    \"Dec 23\",\n    \"Jan 24\",\n    \"Feb 24\",\n    \"Mar 24\",\n    \"Apr 24\",\n    \"May 24\",\n    \"Jun 24\",\n    \"Jul 24\",\n    \"Aug 24\",\n    \"Sep 24\",\n    \"Oct 24\",\n    \"Nov 24\",\n    \"Dec 24\",\n]\ngenres = [\"Pop\", \"Rock\", \"Hip-Hop\", \"Electronic\", \"Jazz\"]\nbase_values = {\"Pop\": 45, \"Rock\": 35, \"Hip-Hop\": 40, \"Electronic\": 30, \"Jazz\": 15}\n\nraw_series = {}\nfor genre in genres:\n    base = base_values[genre]\n    trend = np.linspace(0, np.random.uniform(-10, 15), months)\n    seasonal = 8 * np.sin(np.linspace(0, 4 * np.pi, months) + np.random.uniform(0, 2 * np.pi))\n    noise = np.random.randn(months) * 3\n    values = base + trend + seasonal + noise\n    values = np.maximum(values, 5)\n    raw_series[genre] = values\n\n# Inside-out layer ordering (Byron & Wattenberg): the genre with the widest\n# peak-to-trough swing sits in the visual center, with progressively calmer\n# genres nested outward on alternating sides. This minimizes the stream's\n# overall \"wiggle\" and gives the eye a natural focal point instead of an\n# arbitrary stacking order.\nswing = {genre: raw_series[genre].max() - raw_series[genre].min() for genre in genres}\nranked = sorted(genres, key=lambda g: swing[g], reverse=True)\nordered = [None] * len(ranked)\nleft, right = len(ranked) // 2, len(ranked) // 2\nfor i, genre in enumerate(ranked):\n    if i == 0:\n        ordered[left] = genre\n    elif i % 2 == 1:\n        left -= 1\n        ordered[left] = genre\n    else:\n        right += 1\n        ordered[right] = genre\ngenres = ordered\ndata = {genre: raw_series[genre].tolist() for genre in genres}\n\n# The genre with the strongest net growth (Dec 24 vs Jan 23) is the one worth\n# drawing the eye to — give it a bolder stroke while the rest stay at the\n# shared baseline weight.\ngrowth = {genre: raw_series[genre][-1] - raw_series[genre][0] for genre in genres}\nfeatured_genre = max(growth, key=growth.get)\n\n# True streamgraph baseline: center the entire stack symmetrically around y=0.\n# baseline[t] = -total[t]/2 so the stack spans from -total/2 to +total/2.\ndata_array = np.array([data[genre] for genre in genres])\ntotal_at_each_time = data_array.sum(axis=0)\nbaseline = (-total_at_each_time / 2).tolist()\n\n# Symmetric y-axis range with a small margin\nhalf_total_max = total_at_each_time.max() / 2\ny_range = half_total_max * 1.12\n\n# Style\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=COLORS,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n    opacity=0.88,\n    opacity_hover=0.95,\n)\n\n# Chart\n# The centered baseline has no absolute meaning (it's an invisible offset,\n# not a real per-genre value), so per streamgraph convention the y-axis\n# ticks/gridlines are hidden entirely (show_y_labels=False) rather than\n# showing numbers a viewer could misread as actual streaming hours.\n# pygal always renders a solid, non-hideable \"major line\" at the position-0\n# x tick regardless of show_x_guides, which left a stray vertical line at\n# the first month; the inline CSS override below removes it for a\n# chrome-free streamgraph look.\nchart = pygal.StackedLine(\n    width=3200,\n    height=1800,\n    title=\"streamgraph-basic · python · pygal · anyplot.ai\",\n    x_title=\"Month\",\n    y_title=\"Streaming Hours\",\n    style=custom_style,\n    fill=True,\n    show_dots=False,\n    show_y_guides=False,\n    show_y_labels=False,\n    show_x_guides=False,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=len(genres) + 1,\n    legend_box_size=26,\n    margin=60,\n    spacing=24,\n    truncate_legend=-1,\n    truncate_label=-1,\n    interpolate=\"cubic\",\n    show_minor_x_labels=False,\n    x_label_rotation=45,\n    range=(-y_range, y_range),\n    css=(\"file://style.css\", \"file://graph.css\", \"inline:.axis.x .line { opacity: 0; }\"),\n)\n\nchart.x_labels = month_labels\nchart.x_labels_major = [\"Jan 23\", \"Jul 23\", \"Jan 24\", \"Jul 24\"]\n\n# Invisible baseline series shifts the stack so genres span -total/2 to +total/2.\n# Color matches background (PAGE_BG) so it renders as transparent.\nchart.add(\"\", baseline)\n\n# Genre series added in inside-out order; the featured (fastest-growing) genre\n# gets a heavier stroke to act as the streamgraph's focal point.\nfor genre in genres:\n    if genre == featured_genre:\n        chart.add(genre, data[genre], stroke_style={\"width\": 4.5})\n    else:\n        chart.add(genre, data[genre])\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}