{"spec_id":"line-win-probability","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nline-win-probability: Win Probability Chart\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 92/100 | Updated: 2026-06-21\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\n\n# Remove script dir from sys.path so `altair` resolves to the package, not this file\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\nalt = importlib.import_module(\"altair\")\npd = importlib.import_module(\"pandas\")\nnp = importlib.import_module(\"numpy\")\nImage = importlib.import_module(\"PIL.Image\")\n\n# Theme tokens — Imprint palette (prompts/default-style-guide.md)\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\"\n\n# Team fill colors — Imprint positions with semantic home/away weight\nHOME_COLOR = \"#009E73\"  # Imprint pos 1 brand green — Eagles home area\nAWAY_COLOR = \"#4467A3\"  # Imprint pos 3 blue — Cowboys away area\nEVENT_COLOR = \"#DDCC77\"  # Imprint amber anchor — key scoring event markers\n\n# Data — Simulated NFL game: Eagles vs Cowboys\nnp.random.seed(42)\n\nquarters = [0, 15, 30, 45, 60]\nquarter_labels = [\"Kickoff\", \"Q2\", \"Q3\", \"Q4\", \"Final\"]\n\nplays = np.linspace(0, 60, 200)\nprob = np.full_like(plays, 0.5)\nevents = []\n\nscoring_plays = [\n    (5, -0.10, \"FG Cowboys 0-3\"),\n    (12, 0.20, \"TD Eagles 7-3\"),\n    (18, -0.18, \"TD Cowboys 7-10\"),\n    (24, 0.15, \"FG Eagles 10-10\"),\n    (31, 0.18, \"TD Eagles 17-10\"),\n    (37, -0.22, \"TD Cowboys 17-17\"),\n    (40, -0.12, \"FG Cowboys 17-20\"),\n    (48, 0.28, \"TD Eagles 24-20\"),\n    (53, 0.10, \"FG Eagles 27-20\"),\n    (58, 0.08, \"INT Eagles seal it\"),\n]\n\nfor i in range(1, len(plays)):\n    drift = 0.0\n    for event_time, shift, label in scoring_plays:\n        if plays[i - 1] < event_time <= plays[i]:\n            drift += shift\n            events.append((event_time, label))\n    noise = np.random.normal(0, 0.008)\n    prob[i] = np.clip(prob[i - 1] + drift + noise, 0.01, 0.99)\n\nprob[-1] = 1.0\nprob[-2] = 0.98\nprob[-3] = 0.95\n\ndf = pd.DataFrame({\"minute\": plays, \"win_prob\": prob})\ndf[\"win_pct\"] = df[\"win_prob\"] * 100\ndf[\"above_50\"] = df[\"win_pct\"].clip(lower=50)\ndf[\"below_50\"] = df[\"win_pct\"].clip(upper=50)\n\ndf_events = pd.DataFrame(events, columns=[\"minute\", \"label\"])\ndf_events[\"win_pct\"] = [np.interp(m, df[\"minute\"], df[\"win_pct\"]) for m in df_events[\"minute\"]]\n\n# Label y-offsets tuned per event to minimise overlap while staying near each data point\n# TD Eagles 24-20 (index 7): nudge reduced from -14 to -5 to close the visual gap\nlabel_nudges = [8, -12, -12, -10, 7, 10, -10, -5, 7, 7]\ndf_events[\"label_y\"] = np.clip(df_events[\"win_pct\"] + label_nudges, 5, 97)\n\ndf_events_left = df_events[df_events[\"minute\"] <= 50].copy()\ndf_events_right = df_events[df_events[\"minute\"] > 50].copy()\n\ndf_quarters = pd.DataFrame({\"minute\": quarters, \"label\": quarter_labels})\n\n# Title — length-scaled fontSize (67-char baseline → 16px; see plot-generator.md)\ntitle_text = \"Eagles vs Cowboys · line-win-probability · python · altair · anyplot.ai\"\nn = len(title_text)\ntitle_fs = max(11, round(16 * 67 / n)) if n > 67 else 16\n\n# Plot layers\nbase = alt.Chart(df)\n\nbaseline = base.mark_rule(strokeDash=[6, 4], strokeWidth=2, color=INK_SOFT).encode(y=alt.datum(50))\n\narea_home = base.mark_area(interpolate=\"monotone\", opacity=0.4, color=HOME_COLOR).encode(\n    x=alt.X(\"minute:Q\", title=\"Game Time (minutes)\", scale=alt.Scale(domain=[0, 60])),\n    y=alt.Y(\"above_50:Q\", title=\"Win Probability (%)\", scale=alt.Scale(domain=[0, 100])),\n    y2=alt.datum(50),\n)\n\narea_away = base.mark_area(interpolate=\"monotone\", opacity=0.4, color=AWAY_COLOR).encode(\n    x=\"minute:Q\", y=alt.Y(\"below_50:Q\", scale=alt.Scale(domain=[0, 100])), y2=alt.datum(50)\n)\n\nline = base.mark_line(interpolate=\"monotone\", strokeWidth=3.5, color=INK).encode(\n    x=\"minute:Q\",\n    y=alt.Y(\"win_pct:Q\"),\n    tooltip=[\n        alt.Tooltip(\"minute:Q\", title=\"Minute\", format=\".1f\"),\n        alt.Tooltip(\"win_pct:Q\", title=\"Win Prob %\", format=\".1f\"),\n    ],\n)\n\n# Interactive crosshair selection (visible in HTML; PNG captures final static state)\nnearest = alt.selection_point(nearest=True, on=\"pointerover\", fields=[\"minute\"], empty=False)\n\nselectors = base.mark_point(size=1, opacity=0).encode(x=\"minute:Q\").add_params(nearest)\n\ncrosshair_rule = base.mark_rule(color=INK_SOFT, strokeWidth=1, strokeDash=[3, 3]).encode(\n    x=\"minute:Q\", opacity=alt.condition(nearest, alt.value(0.7), alt.value(0))\n)\n\nhighlight_dot = base.mark_circle(size=180, color=INK, stroke=PAGE_BG, strokeWidth=2).encode(\n    x=\"minute:Q\", y=\"win_pct:Q\", opacity=alt.condition(nearest, alt.value(1), alt.value(0))\n)\n\n# Scoring event markers — amber anchor for contrast against both team fills\nevent_points = (\n    alt.Chart(df_events)\n    .mark_circle(size=220, color=EVENT_COLOR, stroke=INK, strokeWidth=2)\n    .encode(\n        x=\"minute:Q\",\n        y=\"win_pct:Q\",\n        tooltip=[alt.Tooltip(\"label:N\", title=\"Event\"), alt.Tooltip(\"minute:Q\", title=\"Minute\", format=\".0f\")],\n    )\n)\n\nevent_labels_left = (\n    alt.Chart(df_events_left)\n    .mark_text(fontSize=13, fontWeight=\"bold\", align=\"left\", dx=10, color=INK)\n    .encode(x=\"minute:Q\", y=\"label_y:Q\", text=\"label:N\")\n)\n\nevent_labels_right = (\n    alt.Chart(df_events_right)\n    .mark_text(fontSize=13, fontWeight=\"bold\", align=\"right\", dx=-10, color=INK)\n    .encode(x=\"minute:Q\", y=\"label_y:Q\", text=\"label:N\")\n)\n\nquarter_rules = (\n    alt.Chart(df_quarters[1:-1]).mark_rule(strokeDash=[4, 3], strokeWidth=1.5, color=INK_MUTED).encode(x=\"minute:Q\")\n)\n\nquarter_text = (\n    alt.Chart(df_quarters)\n    .mark_text(fontSize=10, fontWeight=\"bold\", dy=-8, color=INK_MUTED)\n    .encode(x=\"minute:Q\", y=alt.datum(100), text=\"label:N\")\n)\n\nchart = (\n    (\n        area_home\n        + area_away\n        + baseline\n        + line\n        + event_points\n        + event_labels_left\n        + event_labels_right\n        + quarter_rules\n        + quarter_text\n        + selectors\n        + crosshair_rule\n        + highlight_dot\n    )\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        title=alt.Title(\n            title_text,\n            fontSize=title_fs,\n            subtitle=\"Final Score: Eagles 27 – Cowboys 20\",\n            subtitleFontSize=11,\n            subtitleColor=INK_SOFT,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        grid=False,\n    )\n    .configure_title(color=INK)\n)\n\n# Save — theme-suffixed filenames required by pipeline\nTW, TH = 3200, 1800\n\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Pad to exact target canvas (altair.md canvas rule — do NOT crop)\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"}