{"spec_id":"slope-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nslope-basic: Basic Slope Chart (Slopegraph)\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 87/100 | Created: 2026-07-25\n\"\"\"\n\nimport os\n\nimport altair as alt\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: position 1 = Increase, position 5 (deferred semantic-red anchor) = Decrease\nCOLOR_INCREASE = \"#009E73\"\nCOLOR_DECREASE = \"#AE3030\"\n\n# Data — Q1 vs Q4 product sales, deliberately spaced (min gap ~90 units within each\n# period) so entity labels never crowd, with a full rank shuffle for a rich story\ndata = pd.DataFrame(\n    {\n        \"Product\": [\n            \"Webcam\",\n            \"Speaker\",\n            \"Charger\",\n            \"Monitor\",\n            \"Headphones\",\n            \"Keyboard\",\n            \"Tablet\",\n            \"Mouse\",\n            \"Laptop\",\n            \"Phone\",\n        ],\n        \"Q1 Sales\": [190, 310, 400, 490, 580, 680, 780, 890, 1010, 1150],\n        \"Q4 Sales\": [400, 190, 310, 580, 780, 490, 890, 680, 1150, 1010],\n    }\n)\n\ndf_long = pd.melt(data, id_vars=[\"Product\"], value_vars=[\"Q1 Sales\", \"Q4 Sales\"], var_name=\"Period\", value_name=\"Sales\")\ndata[\"Direction\"] = data.apply(lambda row: \"Increase\" if row[\"Q4 Sales\"] > row[\"Q1 Sales\"] else \"Decrease\", axis=1)\ndf_long = df_long.merge(data[[\"Product\", \"Direction\"]], on=\"Product\")\n\ncolor_scale = alt.Scale(domain=[\"Increase\", \"Decrease\"], range=[COLOR_INCREASE, COLOR_DECREASE])\n\n# Title (42 chars < 67 baseline — no fontsize reduction needed)\ntitle = \"slope-basic · python · altair · anyplot.ai\"\ntitle_fontsize = 16\n\n# Matte-red \"Decrease\" measures ~2.7:1 against #1A1A17 (below the 3:1 AA floor for\n# meaningful status info); per the style guide's stroke recommendation, outline\n# Decrease elements with a 1px ink halo in dark mode only (fine on the light bg).\ndecrease_stroke_width = alt.condition(\n    alt.datum.Direction == \"Decrease\", alt.value(1 if THEME == \"dark\" else 0), alt.value(0)\n)\n\n# Plot\nlines = (\n    alt.Chart(df_long)\n    .mark_line(strokeWidth=3, opacity=0.8)\n    .encode(\n        x=alt.X(\"Period:N\", axis=alt.Axis(labelFontSize=11, title=None, labelAngle=0)),\n        y=alt.Y(\n            \"Sales:Q\",\n            axis=alt.Axis(labelFontSize=10, titleFontSize=12, title=\"Sales (units)\"),\n            scale=alt.Scale(zero=False),\n        ),\n        color=alt.Color(\n            \"Direction:N\", scale=color_scale, legend=alt.Legend(titleFontSize=12, labelFontSize=10, orient=\"top-right\")\n        ),\n        detail=\"Product:N\",\n    )\n)\n\nlines_decrease_halo = (\n    alt.Chart(df_long[df_long[\"Direction\"] == \"Decrease\"])\n    .mark_line(strokeWidth=5, opacity=0.9, color=INK)\n    .encode(x=\"Period:N\", y=\"Sales:Q\", detail=\"Product:N\")\n)\n\npoints = (\n    alt.Chart(df_long)\n    .mark_circle(size=200, opacity=0.9)\n    .encode(\n        x=\"Period:N\",\n        y=\"Sales:Q\",\n        color=alt.Color(\"Direction:N\", scale=color_scale, legend=None),\n        stroke=alt.value(INK),\n        strokeWidth=decrease_stroke_width,\n    )\n)\n\nlabels_left = (\n    alt.Chart(df_long[df_long[\"Period\"] == \"Q1 Sales\"])\n    .mark_text(align=\"right\", dx=-12, fontSize=11)\n    .encode(\n        x=\"Period:N\",\n        y=\"Sales:Q\",\n        text=\"Product:N\",\n        color=alt.Color(\"Direction:N\", scale=color_scale, legend=None),\n        stroke=alt.value(INK),\n        strokeWidth=decrease_stroke_width,\n    )\n)\n\nlabels_right = (\n    alt.Chart(df_long[df_long[\"Period\"] == \"Q4 Sales\"])\n    .mark_text(align=\"left\", dx=12, fontSize=11)\n    .encode(\n        x=\"Period:N\",\n        y=\"Sales:Q\",\n        text=\"Product:N\",\n        color=alt.Color(\"Direction:N\", scale=color_scale, legend=None),\n        stroke=alt.value(INK),\n        strokeWidth=decrease_stroke_width,\n    )\n)\n\n# Layer order: dark-mode Decrease halo sits beneath everything else; empty in light mode.\nlayers = [lines_decrease_halo] if THEME == \"dark\" else []\nlayers += [lines, points, labels_left, labels_right]\n\n# Style — L-shaped frame: no view stroke, axis domain lines (bottom + left) stay visible\nchart = (\n    alt.layer(*layers)\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(title, fontSize=title_fontsize, color=INK, anchor=\"middle\"),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        grid=True,\n        gridColor=INK,\n        gridOpacity=0.12,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n    )\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save PNG then pad to exact 3200×1800 target\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    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{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 interactive HTML\nchart.save(f\"plot-{THEME}.html\")\n"}