{"spec_id":"burndown-sprint","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nburndown-sprint: Agile Sprint Burndown Chart\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 86/100 | Created: 2026-06-14\n\"\"\"\n\nimport io\nimport os\nimport sys\nimport time\nfrom datetime import date, datetime, timezone\nfrom pathlib import Path\n\n\n# Remove current dir from sys.path so bokeh.py doesn't shadow the bokeh package\nsys.path = [p for p in sys.path if p != \"\" and not (os.path.isfile(os.path.join(p, \"bokeh.py\")) if p else False)]\n\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BoxAnnotation, ColumnDataSource, Label, Span\nfrom bokeh.plotting import figure\nfrom PIL import Image\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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 — canonical order, theme-independent\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRAND = IMPRINT_PALETTE[0]  # actual remaining (green)\nIDEAL_CLR = IMPRINT_PALETTE[2]  # ideal burndown (blue)\nSCOPE_CLR = IMPRINT_PALETTE[4]  # scope change marker (red — semantic: bad/risk)\n# Lighter red for dark theme to meet WCAG large-text 3:1 contrast against #1A1A17\nSCOPE_ANNOTATION_CLR = SCOPE_CLR if THEME == \"light\" else \"#D06060\"\n\n\n# Data: 10-working-day sprint Jun 1–12 2026, weekends Jun 6–7\n# Scope change on Jun 8: +8 pts added, so remaining jumps from 22 → 30\nsprint_dates = [\n    date(2026, 6, 1),  # Mon WD1 — sprint start\n    date(2026, 6, 2),  # Tue WD2\n    date(2026, 6, 3),  # Wed WD3\n    date(2026, 6, 4),  # Thu WD4\n    date(2026, 6, 5),  # Fri WD5\n    date(2026, 6, 6),  # Sat — weekend (flat)\n    date(2026, 6, 7),  # Sun — weekend (flat)\n    date(2026, 6, 8),  # Mon WD6 — scope added (+8 pts)\n    date(2026, 6, 9),  # Tue WD7\n    date(2026, 6, 10),  # Wed WD8\n    date(2026, 6, 11),  # Thu WD9\n    date(2026, 6, 12),  # Fri WD10 — sprint end\n]\nremaining = [40, 36, 32, 25, 22, 22, 22, 30, 22, 14, 7, 0]\n\nn = len(sprint_dates)\nideal = [40 * (1 - i / (n - 1)) for i in range(n)]\n\ndates_ms = [datetime(d.year, d.month, d.day, tzinfo=timezone.utc).timestamp() * 1000 for d in sprint_dates]\nweekend_start_ms = datetime(2026, 6, 6, tzinfo=timezone.utc).timestamp() * 1000\nweekend_end_ms = datetime(2026, 6, 7, tzinfo=timezone.utc).timestamp() * 1000 + 86_400_000\nscope_ms = datetime(2026, 6, 8, tzinfo=timezone.utc).timestamp() * 1000\n\n# Title — 63 chars, under 67 baseline, so title_pt stays at 50\nTITLE = \"Sprint Burndown · burndown-sprint · python · bokeh · anyplot.ai\"\ntitle_pt = max(34, round(50 * 67 / len(TITLE))) if len(TITLE) > 67 else 50\n\n# Plot\nsource = ColumnDataSource(data={\"dates\": dates_ms, \"remaining\": remaining, \"ideal\": ideal})\n\np = figure(\n    width=3200,\n    height=1800,\n    x_axis_type=\"datetime\",\n    title=TITLE,\n    x_axis_label=\"Sprint Day\",\n    y_axis_label=\"Remaining Story Points\",\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=80,\n)\n\n# Weekend shading (subtle band — flat segments in the burndown fall on weekends)\np.add_layout(\n    BoxAnnotation(left=weekend_start_ms, right=weekend_end_ms, fill_color=INK, fill_alpha=0.10, line_color=None)\n)\n\n# Ideal burndown — straight reference line (blue dashed)\np.line(\n    x=\"dates\",\n    y=\"ideal\",\n    source=source,\n    line_color=IDEAL_CLR,\n    line_width=3.5,\n    line_dash=\"dashed\",\n    legend_label=\"Ideal Burndown\",\n)\n\n# Actual remaining — step series (green, mode=after: steps down at day-end)\np.step(\n    x=\"dates\",\n    y=\"remaining\",\n    source=source,\n    mode=\"after\",\n    line_color=BRAND,\n    line_width=5,\n    legend_label=\"Actual Remaining\",\n)\n\n# Day-end dots on actual remaining for each data point\np.scatter(x=\"dates\", y=\"remaining\", source=source, size=18, color=BRAND, line_color=PAGE_BG, line_width=2.5)\n\n# Scope change vertical marker (red dotdash line)\np.add_layout(Span(location=scope_ms, dimension=\"height\", line_color=SCOPE_CLR, line_width=2.5, line_dash=\"dotdash\"))\n\n# Scope change annotation — lighter red on dark theme for WCAG 3:1 contrast\np.add_layout(\n    Label(\n        x=scope_ms + 3_600_000,  # 1 hr right of the span\n        y=33,\n        text=\"+8 pts scope added\",\n        text_color=SCOPE_ANNOTATION_CLR,\n        text_font_size=\"22pt\",\n        text_font_style=\"italic\",\n    )\n)\n\n# Y-axis floor at 0\np.y_range.start = 0\n\n# Style — theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None  # remove 4-sided border; axes provide L-shaped frame\n\np.title.text_font_size = f\"{title_pt}pt\"\np.title.text_color = INK\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\n\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\n\np.xgrid.grid_line_color = None  # no x-grid for cleaner time-series look\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.12\n\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.label_text_color = INK_SOFT\np.legend.label_text_font_size = \"34pt\"\np.legend.location = \"top_right\"\n\n# Save HTML (interactive catalog artifact)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome — window taller than target so browser chrome\n# overhead doesn't clip the figure; PIL crops to exact 3200×1800 canvas.\nFIG_W, FIG_H = 3200, 1800\nWIN_W, WIN_H = FIG_W, FIG_H + 200\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    \"--force-device-scale-factor=1\",\n    f\"--window-size={WIN_W},{WIN_H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(WIN_W, WIN_H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\nraw = driver.get_screenshot_as_png()\ndriver.quit()\nImage.open(io.BytesIO(raw)).crop((0, 0, FIG_W, FIG_H)).save(f\"plot-{THEME}.png\")\n"}