{"spec_id":"line-multi","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nline-multi: Multi-Line Comparison Plot\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 94/100 | Updated: 2026-08-05\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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\"\n\n# Imprint palette for categorical data\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Data - Monthly sales for 4 product lines over 24 months\nnp.random.seed(42)\n\nmonths = pd.date_range(start=\"2023-01-01\", periods=24, freq=\"ME\")\nproducts = [\"Electronics\", \"Furniture\", \"Clothing\", \"Books\"]\n\n# Distinct trends per product line; Electronics is the hero series (brand green, strongest growth)\nbase = np.linspace(100, 150, 24)\nelectronics = base + np.cumsum(np.random.randn(24) * 5) + 50\nfurniture = base * 0.8 + np.cumsum(np.random.randn(24) * 4)\nclothing = base * 1.1 + np.sin(np.linspace(0, 4 * np.pi, 24)) * 20 + np.random.randn(24) * 3\nbooks = base * 0.6 + np.cumsum(np.random.randn(24) * 3) - 20\n\ndf = pd.DataFrame(\n    {\n        \"Month\": np.tile(months, 4),\n        \"Sales (thousands)\": np.concatenate([electronics, furniture, clothing, books]),\n        \"Product\": np.repeat(products, 24),\n    }\n)\n\nis_hero = alt.datum.Product == \"Electronics\"\n\n# Shared encodings; strokeWidth/opacity condition on the hero series to build\n# a visual hierarchy instead of treating all four lines equally.\nbase_chart = alt.Chart(df).encode(\n    x=alt.X(\n        \"Month:T\",\n        title=\"Month\",\n        axis=alt.Axis(\n            grid=False,\n            labelFontSize=10,\n            titleFontSize=12,\n            format=\"%b %Y\",\n            labelColor=INK_SOFT,\n            titleColor=INK,\n            domainColor=INK_SOFT,\n            tickColor=INK_SOFT,\n        ),\n    ),\n    y=alt.Y(\n        \"Sales (thousands):Q\",\n        title=\"Sales (thousands USD)\",\n        axis=alt.Axis(\n            labelFontSize=10,\n            titleFontSize=12,\n            labelColor=INK_SOFT,\n            titleColor=INK,\n            domainColor=INK_SOFT,\n            tickColor=INK_SOFT,\n            gridOpacity=0.15,\n            gridColor=INK,\n        ),\n    ),\n    color=alt.Color(\n        \"Product:N\",\n        scale=alt.Scale(domain=products, range=IMPRINT),\n        legend=alt.Legend(\n            title=\"Product Line\",\n            titleFontSize=10,\n            titleColor=INK,\n            labelFontSize=10,\n            labelColor=INK_SOFT,\n            orient=\"right\",\n            symbolStrokeWidth=3,\n            symbolSize=140,\n            fillColor=ELEVATED_BG,\n            strokeColor=INK_SOFT,\n        ),\n    ),\n    strokeWidth=alt.condition(is_hero, alt.value(3.4), alt.value(1.8)),\n    opacity=alt.condition(is_hero, alt.value(1.0), alt.value(0.7)),\n    tooltip=[\"Month:T\", \"Sales (thousands):Q\", \"Product:N\"],\n)\n\nlines = base_chart.mark_line()\nhero_points = base_chart.transform_filter(is_hero).mark_point(size=70, filled=True)\n\n# Direct-label callout on the hero series' final point (data storytelling)\nhero_last = df[df[\"Product\"] == \"Electronics\"].iloc[[-1]].copy()\nhero_last[\"Label\"] = hero_last[\"Sales (thousands)\"].round(0).astype(int).astype(str) + \"k\"\nhero_label = (\n    alt.Chart(hero_last)\n    .mark_text(align=\"center\", dy=-16, fontSize=11, fontWeight=\"bold\", color=IMPRINT[0])\n    .encode(x=\"Month:T\", y=\"Sales (thousands):Q\", text=\"Label:N\")\n)\n\nchart = (\n    (lines + hero_points + hero_label)\n    .properties(\n        width=620,\n        height=320,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        background=PAGE_BG,\n        title=alt.Title(text=\"line-multi · altair · anyplot.ai\", fontSize=16, anchor=\"middle\", color=INK),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None, continuousWidth=620, continuousHeight=320)\n)\n\n# Save as PNG — hard target 3200x1800 (landscape), see prompts/library/altair.md\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    # vl-convert overshot the inner-view target — a real bug in the chart\n    # definition. Fail loudly so impl-repair triggers; never crop (clips\n    # title/axis labels and trips the AR-09 edge-clipping auto-reject).\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save as HTML for interactivity\nchart.interactive().save(f\"plot-{THEME}.html\")\n"}