{"spec_id":"line-win-probability","library":"d3","language":"javascript","code":"// anyplot.ai\n// line-win-probability: Win Probability Chart\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 94/100 | Created: 2026-06-21\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\nconst margin = { top: 80, right: 160, bottom: 100, left: 90 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// Portland Trail Blazers (home) vs Houston Rockets — synthetic NBA play-by-play\n// win probability over 48 minutes; each row is a significant game event\nconst gameData = [\n  { t: 0.0, p: 0.500 },\n  { t: 1.0, p: 0.520 },\n  { t: 2.0, p: 0.550 },\n  { t: 3.5, p: 0.540 },\n  { t: 5.0, p: 0.580 },\n  { t: 6.0, p: 0.600 },  // Portland opens up a 9-4 run\n  { t: 7.0, p: 0.580 },\n  { t: 8.5, p: 0.620 },\n  { t: 10.0, p: 0.650 },\n  { t: 11.5, p: 0.640 },\n  { t: 12.0, p: 0.630 },  // End Q1: Portland 28-22\n  { t: 13.0, p: 0.610 },\n  { t: 14.5, p: 0.570 },\n  { t: 16.0, p: 0.530 },\n  { t: 17.0, p: 0.480 },\n  { t: 18.0, p: 0.410 },  // Houston 10-2 run, takes lead\n  { t: 19.0, p: 0.430 },\n  { t: 20.0, p: 0.450 },\n  { t: 21.0, p: 0.460 },\n  { t: 22.0, p: 0.475 },\n  { t: 23.0, p: 0.460 },\n  { t: 24.0, p: 0.450 },  // Halftime: Portland 47-48\n  { t: 25.0, p: 0.470 },\n  { t: 26.5, p: 0.510 },\n  { t: 28.0, p: 0.540 },\n  { t: 29.0, p: 0.560 },\n  { t: 30.0, p: 0.575 },\n  { t: 31.5, p: 0.540 },\n  { t: 33.0, p: 0.530 },\n  { t: 34.5, p: 0.565 },\n  { t: 36.0, p: 0.570 },  // End Q3: Portland 83-78\n  { t: 37.0, p: 0.550 },\n  { t: 38.5, p: 0.520 },\n  { t: 39.5, p: 0.480 },\n  { t: 40.5, p: 0.440 },\n  { t: 41.0, p: 0.390 },  // Houston 3PT + foul, 4-point lead\n  { t: 42.0, p: 0.430 },\n  { t: 43.0, p: 0.500 },\n  { t: 44.0, p: 0.580 },  // Lillard 3-pointer with 4 min left\n  { t: 45.0, p: 0.680 },  // Houston star fouls out\n  { t: 46.0, p: 0.750 },\n  { t: 47.0, p: 0.820 },\n  { t: 48.0, p: 0.850 },  // Final: Portland 112 - Houston 108\n];\n\n// Key events to annotate (significant probability swings)\nconst keyEvents = [\n  { t: 6.0,  p: 0.600, label: \"Portland 9-4 run\", above: true  },\n  { t: 18.0, p: 0.410, label: \"Houston 10-2 run\",  above: false },\n  { t: 41.0, p: 0.390, label: \"Houston 3PT+foul\",  above: false },\n  { t: 44.0, p: 0.580, label: \"Lillard 3-PT\",       above: true  },\n];\n\nconst HOME_COLOR = t.palette[0];  // #009E73 — Portland home team (Imprint position 1)\nconst AWAY_COLOR = t.palette[4];  // #AE3030 — Houston away team (semantic loss anchor)\n\n// SVG\nconst svg = d3.select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\nconst g = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Scales\nconst x = d3.scaleLinear().domain([0, 48]).range([0, iw]);\nconst y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);\nconst midY = y(0.5);\n\n// Area fills (Math.min/max trick splits fill at the 50% baseline)\nconst homeArea = d3.area()\n  .x(d => x(d.t))\n  .y0(midY)\n  .y1(d => Math.min(y(d.p), midY))  // fills above 50% only\n  .curve(d3.curveMonotoneX);\n\nconst awayArea = d3.area()\n  .x(d => x(d.t))\n  .y0(midY)\n  .y1(d => Math.max(y(d.p), midY))  // fills below 50% only\n  .curve(d3.curveMonotoneX);\n\n// Win probability line\nconst lineGen = d3.line()\n  .x(d => x(d.t))\n  .y(d => y(d.p))\n  .curve(d3.curveMonotoneX);\n\n// Draw areas first (behind line)\ng.append(\"path\").datum(gameData)\n  .attr(\"fill\", HOME_COLOR).attr(\"fill-opacity\", 0.22)\n  .attr(\"d\", homeArea);\n\ng.append(\"path\").datum(gameData)\n  .attr(\"fill\", AWAY_COLOR).attr(\"fill-opacity\", 0.22)\n  .attr(\"d\", awayArea);\n\n// Subtle horizontal grid lines at 25% and 75%\n[0.25, 0.75].forEach(pct => {\n  g.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", y(pct)).attr(\"y2\", y(pct))\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 0.8);\n});\n\n// Quarter boundary vertical lines (dashed)\n[12, 24, 36].forEach(qt => {\n  g.append(\"line\")\n    .attr(\"x1\", x(qt)).attr(\"x2\", x(qt))\n    .attr(\"y1\", 0).attr(\"y2\", ih)\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", 1)\n    .attr(\"stroke-dasharray\", \"6,4\");\n});\n\n// 50% reference line (prominent dashed)\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", midY).attr(\"y2\", midY)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"8,4\");\n\n// Quarter period labels near top of chart area\nconst quarterCenters = [6, 18, 30, 42];\nconst quarterNames   = [\"Q1\", \"Q2\", \"Q3\", \"Q4\"];\nquarterCenters.forEach((mid, i) => {\n  g.append(\"text\")\n    .attr(\"x\", x(mid)).attr(\"y\", 18)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .style(\"font-weight\", \"500\")\n    .text(quarterNames[i]);\n});\n\n// Win probability line (drawn on top of area fills)\ng.append(\"path\").datum(gameData)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 2.5)\n  .attr(\"d\", lineGen);\n\n// X-axis\nconst xAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x)\n    .tickValues([0, 12, 24, 36, 48])\n    .tickFormat(d => `${d}m`));\n\n// Y-axis\nconst yAxis = g.append(\"g\")\n  .call(d3.axisLeft(y)\n    .tickValues([0, 0.25, 0.5, 0.75, 1.0])\n    .tickFormat(d => `${Math.round(d * 100)}%`));\n\n// Style both axes\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\".tick line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// \"50%\" label beside the reference line (right edge of chart area)\ng.append(\"text\")\n  .attr(\"x\", iw + 6).attr(\"y\", midY + 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\")\n  .text(\"50%\");\n\n// Team name labels in the right margin\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw + 10)\n  .attr(\"y\", margin.top + midY - 38)\n  .attr(\"fill\", HOME_COLOR)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Portland\");\n\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw + 10)\n  .attr(\"y\", margin.top + midY + 58)\n  .attr(\"fill\", AWAY_COLOR)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Houston\");\n\n// Key event annotation markers and labels\nkeyEvents.forEach(ev => {\n  const cx = x(ev.t);\n  const cy = y(ev.p);\n  const color = ev.above ? HOME_COLOR : AWAY_COLOR;\n\n  g.append(\"circle\")\n    .attr(\"cx\", cx).attr(\"cy\", cy).attr(\"r\", 6)\n    .attr(\"fill\", color)\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 1.5);\n\n  g.append(\"text\")\n    .attr(\"x\", cx)\n    .attr(\"y\", ev.above ? cy - 13 : cy + 24)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"12px\")\n    .text(ev.label);\n});\n\n// Final score annotation (upper-right corner)\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw)\n  .attr(\"y\", margin.top - 14)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Final: Portland 112 – Houston 108\");\n\n// Axis labels\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 8)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Game Time (minutes)\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(margin.top + ih / 2))\n  .attr(\"y\", 18)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Win Probability (Home Team)\");\n\n// Title\nconst title = \"line-win-probability · javascript · d3 · anyplot.ai\";\nconst titleFontSize = title.length > 67 ? Math.round(22 * 67 / title.length) : 22;\n\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n"}