Files
palladium/template/MercuryNotebook/notebooks/business_case.ipynb

1936 lines
89 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"cells": [
{
"cell_type": "markdown",
"id": "cell-0",
"metadata": {},
"source": [
"# [Study Title] — Business Case\n",
"\n",
"**Thesis:** one paragraph stating what this notebook demonstrates and the frame it uses\n",
"(here: a platform migration priced against *doing nothing*, with the vendor's pitched\n",
"numbers kept verbatim as the anchor and the signed contract layered over them).\n",
"\n",
"This notebook **is** the deliverable: serve it interactively with\n",
"`mercury --working-dir notebooks/`, tune the 🟡 inputs live for the client, then export\n",
"an LLM-readable report source with `python scripts/export_report.py`. All math lives in\n",
"`studylib/` — the notebook renders it.\n",
"\n",
"Confidence legend: 🟢 confirmed (published/contractual) · 🟡 estimated (working\n",
"assumption) · 🔴 unknown."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "cell-1",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-08T16:36:50.535111Z",
"iopub.status.busy": "2026-07-08T16:36:50.534789Z",
"iopub.status.idle": "2026-07-08T16:36:50.959725Z",
"shell.execute_reply": "2026-07-08T16:36:50.956659Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"studylib loaded — window 20262028\n"
]
}
],
"source": [
"# ── Setup ──────────────────────────────────────────────────────────\n",
"import sys, pathlib\n",
"_ROOT = pathlib.Path.cwd()\n",
"if not (_ROOT / \"studylib\").exists(): # notebook lives in notebooks/\n",
" _ROOT = _ROOT.parent\n",
"sys.path.insert(0, str(_ROOT))\n",
"\n",
"import pandas as pd\n",
"import plotly.graph_objects as go\n",
"\n",
"import mercury as mr\n",
"\n",
"# Single source of truth — all math lives in the library; only\n",
"# presentation (and Mercury input widgets) lives here.\n",
"from studylib.model import (\n",
" YEARS, ANCHOR_VERBATIM, DEFAULT_RAMP_MONTHS,\n",
" anchor, benefits_by_year, case_flows, case_kpis, current_costs_by_year,\n",
" money, html_money, payback_label, platform_costs_by_year, services_by_year,\n",
")\n",
"from studylib.staging import backstage\n",
"\n",
"pd.options.display.float_format = \"{:,.0f}\".format\n",
"\n",
"# ── Chart chrome (dataviz reference palette, light surface) ─────────\n",
"INK, INK2, MUTED = \"#0b0b0b\", \"#52514e\", \"#898781\"\n",
"SURFACE, GRID = \"#fcfcfb\", \"#e1e0d9\"\n",
"CUMULATIVE, CONTEXT = \"#52514e\", \"#c3c2b7\"\n",
"FONT_STACK = 'system-ui, -apple-system, \"Segoe UI\", sans-serif'\n",
"\n",
"\n",
"def tei_layout(fig, title, subtitle=None, height=420):\n",
" \"\"\"House chart chrome — recessive grid, ink text, title top-left.\"\"\"\n",
" t = f\"<b>{title}</b>\"\n",
" if subtitle:\n",
" t += f\"<br><span style='font-size:12px;color:{INK2}'>{subtitle}</span>\"\n",
" fig.update_layout(\n",
" title=dict(text=t, font=dict(size=16, color=INK), x=0, xanchor=\"left\"),\n",
" font=dict(family=FONT_STACK, size=12, color=INK),\n",
" paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,\n",
" margin=dict(l=60, r=30, t=80, b=45), height=height,\n",
" legend=dict(orientation=\"h\", yanchor=\"bottom\", y=1.0, x=0,\n",
" bgcolor=\"rgba(0,0,0,0)\"),\n",
" xaxis=dict(showgrid=False, zeroline=False),\n",
" yaxis=dict(gridcolor=GRID, zeroline=False, tickformat=\"$,.0f\"),\n",
" hovermode=\"x unified\",\n",
" )\n",
" return fig\n",
"\n",
"\n",
"def bar(x, y, name, color):\n",
" return go.Bar(x=x, y=y, name=name, marker_color=color,\n",
" marker_line=dict(color=SURFACE, width=2),\n",
" hovertemplate=\"%{y:$,.0f}<extra>\" + name + \"</extra>\")\n",
"\n",
"\n",
"def cum_line(x, y, name, color=CUMULATIVE, dash=None):\n",
" return go.Scatter(x=x, y=y, name=name, mode=\"lines+markers\",\n",
" line=dict(color=color, width=2, dash=dash),\n",
" marker=dict(size=8),\n",
" hovertemplate=\"%{y:$,.0f}<extra>\" + name + \"</extra>\")\n",
"\n",
"\n",
"X = [str(y) for y in YEARS]\n",
"backstage(f\"studylib loaded — window {YEARS[0]}{YEARS[-1]}\")"
]
},
{
"cell_type": "markdown",
"id": "cell-2",
"metadata": {},
"source": [
"<a id=\"section-1\"></a>\n",
"\n",
"## 1 · Inputs\n",
"\n",
"Contract inputs collected as live widgets. Sidebar widgets are scenario knobs; use\n",
"`position=\"inline\"` for data-collection tables that belong in the page flow.\n",
"\n",
"> **Reactivity contract:** Mercury re-executes only the cells *below* a changed\n",
"> widget's cell — never the defining cell itself. So the next cell constructs\n",
"> widgets ONLY (no other output), and `.value` is read one cell further down."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "cell-3",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-08T16:36:50.962324Z",
"iopub.status.busy": "2026-07-08T16:36:50.961887Z",
"iopub.status.idle": "2026-07-08T16:36:50.977751Z",
"shell.execute_reply": "2026-07-08T16:36:50.976977Z"
}
},
"outputs": [
{
"data": {
"application/mercury+json": {
"model_id": "924aaf1b9a7244388d4fdc29e0f544e2",
"position": "sidebar",
"widget": "NumberInputWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "924aaf1b9a7244388d4fdc29e0f544e2",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.number.NumberInputWidget object at 0x7f00bfe02cf0>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/mercury+json": {
"model_id": "60e56739936e4f2ab8ce7a25fe31c092",
"position": "sidebar",
"widget": "NumberInputWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "60e56739936e4f2ab8ce7a25fe31c092",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.number.NumberInputWidget object at 0x7f00bfd15950>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/mercury+json": {
"model_id": "56c2f21d17cf40009b6d4bcee7bcdd9a",
"position": "sidebar",
"widget": "SelectWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "56c2f21d17cf40009b6d4bcee7bcdd9a",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.select.SelectWidget object at 0x7f01201d9160>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# ── Inputs (Mercury sidebar — widgets only, NO other output) ────────\n",
"# NB: Mercury re-executes only cells BELOW a changed widget's cell, so\n",
"# this cell constructs widgets ONLY — .value is read downstream.\n",
"_platform_w = mr.NumberInput(label=\"Platform run-rate ($/yr) — contracted\",\n",
" value=round(anchor(\"platform_annual\")),\n",
" min=0, max=10_000_000, step=25_000)\n",
"_ramp_w = mr.NumberInput(label=\"Ramp — billing-free months\",\n",
" value=DEFAULT_RAMP_MONTHS, min=0, max=24, step=3)\n",
"_npv_w = mr.Select(label=\"NPV discount rate\", value=\"10% (vendor)\",\n",
" choices=[\"10% (vendor)\", \"8% (treasury)\"])"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "cell-4",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-08T16:36:50.980200Z",
"iopub.status.busy": "2026-07-08T16:36:50.979922Z",
"iopub.status.idle": "2026-07-08T16:36:50.985441Z",
"shell.execute_reply": "2026-07-08T16:36:50.984712Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"platform by year: {2026: '$250K', 2027: '$500K', 2028: '$500K'}\n",
"platform run-rate: contracted $500K/yr (vendor pitched $600K/yr) · ramp 6 months\n"
]
}
],
"source": [
"# ── Model state (re-runs on any change to the widgets above) ────────\n",
"PLATFORM_ANNUAL = float(_platform_w.value)\n",
"RAMP_MONTHS = int(_ramp_w.value)\n",
"DISCOUNT_RATE = 0.10 if _npv_w.value.startswith(\"10\") else 0.08\n",
"\n",
"platform_by_year = platform_costs_by_year(RAMP_MONTHS, PLATFORM_ANNUAL)\n",
"services_by = services_by_year()\n",
"current_by_year = current_costs_by_year()\n",
"ben_by_year = benefits_by_year()\n",
"BASELINE_ANNUAL = anchor(\"baseline_annual\")\n",
"\n",
"backstage(f\"platform by year: { {y: money(v) for y, v in platform_by_year.items()} }\")\n",
"print(f\"platform run-rate: contracted {money(PLATFORM_ANNUAL)}/yr \"\n",
" f\"(vendor pitched {money(ANCHOR_VERBATIM['platform_annual'])}/yr) · \"\n",
" f\"ramp {RAMP_MONTHS} months\")"
]
},
{
"cell_type": "markdown",
"id": "cell-5",
"metadata": {},
"source": [
"<a id=\"section-2\"></a>\n",
"\n",
"## 2 · Business case vs doing nothing\n",
"\n",
"**Frame:** baseline-relative. Incremental cost = programme cost baseline\n",
"(the do-nothing run-rate); net = benefits incremental cost. Double-billing\n",
"while the old platform runs off and the post-termination cost-avoidance credit\n",
"both fall out of this one frame.\n",
"\n",
"The KPI table keeps a **vendor-frame column** (pitched rate, verbatim anchors)\n",
"beside the **contracted column**, so the client can walk from their own numbers\n",
"to the corrected reality."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "cell-6",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-08T16:36:50.987674Z",
"iopub.status.busy": "2026-07-08T16:36:50.987427Z",
"iopub.status.idle": "2026-07-08T16:36:51.023013Z",
"shell.execute_reply": "2026-07-08T16:36:51.022122Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"net by year: {2026: '-$500K', 2027: '-$200K', 2028: '$1.1M'}\n"
]
},
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>2026</th>\n",
" <th>2027</th>\n",
" <th>2028</th>\n",
" <th>3-yr</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>Platform (contracted, ramp-adjusted)</th>\n",
" <td>250,000</td>\n",
" <td>500,000</td>\n",
" <td>500,000</td>\n",
" <td>1,250,000</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Services (year 1)</th>\n",
" <td>250,000</td>\n",
" <td>0</td>\n",
" <td>0</td>\n",
" <td>250,000</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Existing platform (term-contract run-off)</th>\n",
" <td>1,000,000</td>\n",
" <td>1,000,000</td>\n",
" <td>0</td>\n",
" <td>2,000,000</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" 2026 2027 2028 \\\n",
"Platform (contracted, ramp-adjusted) 250,000 500,000 500,000 \n",
"Services (year 1) 250,000 0 0 \n",
"Existing platform (term-contract run-off) 1,000,000 1,000,000 0 \n",
"\n",
" 3-yr \n",
"Platform (contracted, ramp-adjusted) 1,250,000 \n",
"Services (year 1) 250,000 \n",
"Existing platform (term-contract run-off) 2,000,000 "
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Vendor frame ($600K/yr)</th>\n",
" <th>Contracted ($500K/yr)</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>3-yr benefits</th>\n",
" <td>$900K</td>\n",
" <td>$900K</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3-yr incremental cost</th>\n",
" <td>$750K</td>\n",
" <td>$500K</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3-yr net</th>\n",
" <td>$150K</td>\n",
" <td>$400K</td>\n",
" </tr>\n",
" <tr>\n",
" <th>ROI</th>\n",
" <td>20%</td>\n",
" <td>80%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>NPV @ 10%</th>\n",
" <td>$3K</td>\n",
" <td>$207K</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Payback</th>\n",
" <td>35 months (~Nov 2028)</td>\n",
" <td>32 months (~Aug 2028)</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Vendor frame ($600K/yr) Contracted ($500K/yr)\n",
"3-yr benefits $900K $900K\n",
"3-yr incremental cost $750K $500K\n",
"3-yr net $150K $400K\n",
"ROI 20% 80%\n",
"NPV @ 10% $3K $207K\n",
"Payback 35 months (~Nov 2028) 32 months (~Aug 2028)"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"study_costs = pd.DataFrame({\n",
" \"Platform (contracted, ramp-adjusted)\": platform_by_year,\n",
" \"Services (year 1)\": services_by,\n",
" \"Existing platform (term-contract run-off)\": current_by_year,\n",
"}).T[YEARS]\n",
"study_costs[\"3-yr\"] = study_costs.sum(axis=1)\n",
"total_by_year = {y: float(study_costs[y].sum()) for y in YEARS}\n",
"\n",
"inc, net_by = case_flows(total_by_year, ben_by_year)\n",
"kpi = case_kpis(inc, net_by, DISCOUNT_RATE)\n",
"\n",
"# Vendor-anchored comparison: same frame at the pitched (verbatim) rate.\n",
"_plat_vendor = platform_costs_by_year(RAMP_MONTHS, ANCHOR_VERBATIM[\"platform_annual\"])\n",
"total_vendor = {y: current_by_year[y] + _plat_vendor[y] + services_by[y] for y in YEARS}\n",
"inc_v, net_v = case_flows(total_vendor, ben_by_year)\n",
"kpi_v = case_kpis(inc_v, net_v, DISCOUNT_RATE)\n",
"\n",
"\n",
"def kpi_col(k):\n",
" return {\n",
" \"3-yr benefits\": money(k[\"benefits_3yr\"]),\n",
" \"3-yr incremental cost\": money(k[\"incremental_cost_3yr\"]),\n",
" \"3-yr net\": money(k[\"net_3yr\"]),\n",
" \"ROI\": f\"{k['roi']:.0%}\" if k[\"roi\"] is not None else \"n/a — net saving\",\n",
" f\"NPV @ {k['discount_rate']:.0%}\": money(k[\"npv\"]),\n",
" \"Payback\": k[\"payback\"],\n",
" }\n",
"\n",
"\n",
"kpis_fmt = pd.DataFrame({\n",
" f\"Vendor frame ({money(ANCHOR_VERBATIM['platform_annual'])}/yr)\": kpi_col(kpi_v),\n",
" f\"Contracted ({money(PLATFORM_ANNUAL)}/yr)\": kpi_col(kpi),\n",
"})\n",
"backstage(f\"net by year: { {y: money(v) for y, v in net_by.items()} }\")\n",
"display(study_costs)\n",
"display(kpis_fmt)"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "cell-7",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-08T16:36:51.025383Z",
"iopub.status.busy": "2026-07-08T16:36:51.025145Z",
"iopub.status.idle": "2026-07-08T16:36:52.252524Z",
"shell.execute_reply": "2026-07-08T16:36:52.251601Z"
}
},
"outputs": [
{
"data": {
"application/vnd.plotly.v1+json": {
"config": {
"plotlyServerURL": "https://plot.ly"
},
"data": [
{
"hovertemplate": "%{y:$,.0f}<extra>Benefits</extra>",
"marker": {
"color": "#1baf7a",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Benefits",
"type": "bar",
"x": [
"2026",
"2027",
"2028"
],
"y": [
0.0,
300000.0,
600000.0
]
},
{
"hovertemplate": "%{y:$,.0f}<extra>Incremental cost vs $1.0M/yr baseline</extra>",
"marker": {
"color": "#e34948",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Incremental cost vs $1.0M/yr baseline",
"type": "bar",
"x": [
"2026",
"2027",
"2028"
],
"y": [
-500000.0,
-500000.0,
500000.0
]
},
{
"hovertemplate": "%{y:$,.0f}<extra>Cumulative net</extra>",
"line": {
"color": "#52514e",
"width": 2
},
"marker": {
"size": 8
},
"mode": "lines+markers",
"name": "Cumulative net",
"type": "scatter",
"x": [
"2026",
"2027",
"2028"
],
"y": {
"bdata": "AAAAAICEHsEAAAAAwFwlwQAAAAAAahhB",
"dtype": "f8"
}
}
],
"layout": {
"annotations": [
{
"align": "left",
"bgcolor": "#fcfcfb",
"bordercolor": "#e1e0d9",
"borderwidth": 1,
"font": {
"color": "#52514e",
"size": 12
},
"showarrow": false,
"text": "3-yr net <b>&#36;400K</b> · NPV@10% <b>&#36;207K</b> · payback <b>32 months (~Aug 2028)</b>",
"x": 0.01,
"xref": "paper",
"y": 0.98,
"yref": "paper"
}
],
"barmode": "relative",
"font": {
"color": "#0b0b0b",
"family": "system-ui, -apple-system, \"Segoe UI\", sans-serif",
"size": 12
},
"height": 420,
"hovermode": "x unified",
"legend": {
"bgcolor": "rgba(0,0,0,0)",
"orientation": "h",
"x": 0,
"y": 1.0,
"yanchor": "bottom"
},
"margin": {
"b": 45,
"l": 60,
"r": 30,
"t": 80
},
"paper_bgcolor": "#fcfcfb",
"plot_bgcolor": "#fcfcfb",
"template": {
"data": {
"bar": [
{
"error_x": {
"color": "#2a3f5f"
},
"error_y": {
"color": "#2a3f5f"
},
"marker": {
"line": {
"color": "#E5ECF6",
"width": 0.5
},
"pattern": {
"fillmode": "overlay",
"size": 10,
"solidity": 0.2
}
},
"type": "bar"
}
],
"barpolar": [
{
"marker": {
"line": {
"color": "#E5ECF6",
"width": 0.5
},
"pattern": {
"fillmode": "overlay",
"size": 10,
"solidity": 0.2
}
},
"type": "barpolar"
}
],
"carpet": [
{
"aaxis": {
"endlinecolor": "#2a3f5f",
"gridcolor": "white",
"linecolor": "white",
"minorgridcolor": "white",
"startlinecolor": "#2a3f5f"
},
"baxis": {
"endlinecolor": "#2a3f5f",
"gridcolor": "white",
"linecolor": "white",
"minorgridcolor": "white",
"startlinecolor": "#2a3f5f"
},
"type": "carpet"
}
],
"choropleth": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"type": "choropleth"
}
],
"contour": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"colorscale": [
[
0.0,
"#0d0887"
],
[
0.1111111111111111,
"#46039f"
],
[
0.2222222222222222,
"#7201a8"
],
[
0.3333333333333333,
"#9c179e"
],
[
0.4444444444444444,
"#bd3786"
],
[
0.5555555555555556,
"#d8576b"
],
[
0.6666666666666666,
"#ed7953"
],
[
0.7777777777777778,
"#fb9f3a"
],
[
0.8888888888888888,
"#fdca26"
],
[
1.0,
"#f0f921"
]
],
"type": "contour"
}
],
"contourcarpet": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"type": "contourcarpet"
}
],
"heatmap": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"colorscale": [
[
0.0,
"#0d0887"
],
[
0.1111111111111111,
"#46039f"
],
[
0.2222222222222222,
"#7201a8"
],
[
0.3333333333333333,
"#9c179e"
],
[
0.4444444444444444,
"#bd3786"
],
[
0.5555555555555556,
"#d8576b"
],
[
0.6666666666666666,
"#ed7953"
],
[
0.7777777777777778,
"#fb9f3a"
],
[
0.8888888888888888,
"#fdca26"
],
[
1.0,
"#f0f921"
]
],
"type": "heatmap"
}
],
"histogram": [
{
"marker": {
"pattern": {
"fillmode": "overlay",
"size": 10,
"solidity": 0.2
}
},
"type": "histogram"
}
],
"histogram2d": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"colorscale": [
[
0.0,
"#0d0887"
],
[
0.1111111111111111,
"#46039f"
],
[
0.2222222222222222,
"#7201a8"
],
[
0.3333333333333333,
"#9c179e"
],
[
0.4444444444444444,
"#bd3786"
],
[
0.5555555555555556,
"#d8576b"
],
[
0.6666666666666666,
"#ed7953"
],
[
0.7777777777777778,
"#fb9f3a"
],
[
0.8888888888888888,
"#fdca26"
],
[
1.0,
"#f0f921"
]
],
"type": "histogram2d"
}
],
"histogram2dcontour": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"colorscale": [
[
0.0,
"#0d0887"
],
[
0.1111111111111111,
"#46039f"
],
[
0.2222222222222222,
"#7201a8"
],
[
0.3333333333333333,
"#9c179e"
],
[
0.4444444444444444,
"#bd3786"
],
[
0.5555555555555556,
"#d8576b"
],
[
0.6666666666666666,
"#ed7953"
],
[
0.7777777777777778,
"#fb9f3a"
],
[
0.8888888888888888,
"#fdca26"
],
[
1.0,
"#f0f921"
]
],
"type": "histogram2dcontour"
}
],
"mesh3d": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"type": "mesh3d"
}
],
"parcoords": [
{
"line": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "parcoords"
}
],
"pie": [
{
"automargin": true,
"type": "pie"
}
],
"scatter": [
{
"fillpattern": {
"fillmode": "overlay",
"size": 10,
"solidity": 0.2
},
"type": "scatter"
}
],
"scatter3d": [
{
"line": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scatter3d"
}
],
"scattercarpet": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scattercarpet"
}
],
"scattergeo": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scattergeo"
}
],
"scattergl": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scattergl"
}
],
"scattermap": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scattermap"
}
],
"scattermapbox": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scattermapbox"
}
],
"scatterpolar": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scatterpolar"
}
],
"scatterpolargl": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scatterpolargl"
}
],
"scatterternary": [
{
"marker": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"type": "scatterternary"
}
],
"surface": [
{
"colorbar": {
"outlinewidth": 0,
"ticks": ""
},
"colorscale": [
[
0.0,
"#0d0887"
],
[
0.1111111111111111,
"#46039f"
],
[
0.2222222222222222,
"#7201a8"
],
[
0.3333333333333333,
"#9c179e"
],
[
0.4444444444444444,
"#bd3786"
],
[
0.5555555555555556,
"#d8576b"
],
[
0.6666666666666666,
"#ed7953"
],
[
0.7777777777777778,
"#fb9f3a"
],
[
0.8888888888888888,
"#fdca26"
],
[
1.0,
"#f0f921"
]
],
"type": "surface"
}
],
"table": [
{
"cells": {
"fill": {
"color": "#EBF0F8"
},
"line": {
"color": "white"
}
},
"header": {
"fill": {
"color": "#C8D4E3"
},
"line": {
"color": "white"
}
},
"type": "table"
}
]
},
"layout": {
"annotationdefaults": {
"arrowcolor": "#2a3f5f",
"arrowhead": 0,
"arrowwidth": 1
},
"autotypenumbers": "strict",
"coloraxis": {
"colorbar": {
"outlinewidth": 0,
"ticks": ""
}
},
"colorscale": {
"diverging": [
[
0,
"#8e0152"
],
[
0.1,
"#c51b7d"
],
[
0.2,
"#de77ae"
],
[
0.3,
"#f1b6da"
],
[
0.4,
"#fde0ef"
],
[
0.5,
"#f7f7f7"
],
[
0.6,
"#e6f5d0"
],
[
0.7,
"#b8e186"
],
[
0.8,
"#7fbc41"
],
[
0.9,
"#4d9221"
],
[
1,
"#276419"
]
],
"sequential": [
[
0.0,
"#0d0887"
],
[
0.1111111111111111,
"#46039f"
],
[
0.2222222222222222,
"#7201a8"
],
[
0.3333333333333333,
"#9c179e"
],
[
0.4444444444444444,
"#bd3786"
],
[
0.5555555555555556,
"#d8576b"
],
[
0.6666666666666666,
"#ed7953"
],
[
0.7777777777777778,
"#fb9f3a"
],
[
0.8888888888888888,
"#fdca26"
],
[
1.0,
"#f0f921"
]
],
"sequentialminus": [
[
0.0,
"#0d0887"
],
[
0.1111111111111111,
"#46039f"
],
[
0.2222222222222222,
"#7201a8"
],
[
0.3333333333333333,
"#9c179e"
],
[
0.4444444444444444,
"#bd3786"
],
[
0.5555555555555556,
"#d8576b"
],
[
0.6666666666666666,
"#ed7953"
],
[
0.7777777777777778,
"#fb9f3a"
],
[
0.8888888888888888,
"#fdca26"
],
[
1.0,
"#f0f921"
]
]
},
"colorway": [
"#636efa",
"#EF553B",
"#00cc96",
"#ab63fa",
"#FFA15A",
"#19d3f3",
"#FF6692",
"#B6E880",
"#FF97FF",
"#FECB52"
],
"font": {
"color": "#2a3f5f"
},
"geo": {
"bgcolor": "white",
"lakecolor": "white",
"landcolor": "#E5ECF6",
"showlakes": true,
"showland": true,
"subunitcolor": "white"
},
"hoverlabel": {
"align": "left"
},
"hovermode": "closest",
"mapbox": {
"style": "light"
},
"paper_bgcolor": "white",
"plot_bgcolor": "#E5ECF6",
"polar": {
"angularaxis": {
"gridcolor": "white",
"linecolor": "white",
"ticks": ""
},
"bgcolor": "#E5ECF6",
"radialaxis": {
"gridcolor": "white",
"linecolor": "white",
"ticks": ""
}
},
"scene": {
"xaxis": {
"backgroundcolor": "#E5ECF6",
"gridcolor": "white",
"gridwidth": 2,
"linecolor": "white",
"showbackground": true,
"ticks": "",
"zerolinecolor": "white"
},
"yaxis": {
"backgroundcolor": "#E5ECF6",
"gridcolor": "white",
"gridwidth": 2,
"linecolor": "white",
"showbackground": true,
"ticks": "",
"zerolinecolor": "white"
},
"zaxis": {
"backgroundcolor": "#E5ECF6",
"gridcolor": "white",
"gridwidth": 2,
"linecolor": "white",
"showbackground": true,
"ticks": "",
"zerolinecolor": "white"
}
},
"shapedefaults": {
"line": {
"color": "#2a3f5f"
}
},
"ternary": {
"aaxis": {
"gridcolor": "white",
"linecolor": "white",
"ticks": ""
},
"baxis": {
"gridcolor": "white",
"linecolor": "white",
"ticks": ""
},
"bgcolor": "#E5ECF6",
"caxis": {
"gridcolor": "white",
"linecolor": "white",
"ticks": ""
}
},
"title": {
"x": 0.05
},
"xaxis": {
"automargin": true,
"gridcolor": "white",
"linecolor": "white",
"ticks": "",
"title": {
"standoff": 15
},
"zerolinecolor": "white",
"zerolinewidth": 2
},
"yaxis": {
"automargin": true,
"gridcolor": "white",
"linecolor": "white",
"ticks": "",
"title": {
"standoff": 15
},
"zerolinecolor": "white",
"zerolinewidth": 2
}
}
},
"title": {
"font": {
"color": "#0b0b0b",
"size": 16
},
"text": "<b>Business case vs doing nothing</b><br><span style='font-size:12px;color:#52514e'>Baseline-relative: net = benefits (programme cost baseline)</span>",
"x": 0,
"xanchor": "left"
},
"xaxis": {
"showgrid": false,
"zeroline": false
},
"yaxis": {
"gridcolor": "#e1e0d9",
"tickformat": "$,.0f",
"zeroline": false
}
}
}
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"fig = go.Figure()\n",
"fig.add_trace(bar(X, [ben_by_year[y] for y in YEARS], \"Benefits\", \"#1baf7a\"))\n",
"fig.add_trace(bar(X, [-inc[y] for y in YEARS],\n",
" f\"Incremental cost vs {money(BASELINE_ANNUAL)}/yr baseline\",\n",
" \"#e34948\"))\n",
"cum_net = pd.Series([net_by[y] for y in YEARS]).cumsum()\n",
"fig.add_trace(cum_line(X, cum_net, \"Cumulative net\"))\n",
"fig.update_layout(barmode=\"relative\")\n",
"# Several amounts in one annotation → html_money, or MathJax eats the text.\n",
"fig.add_annotation(\n",
" xref=\"paper\", yref=\"paper\", x=0.01, y=0.98, align=\"left\", showarrow=False,\n",
" font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,\n",
" text=(f\"3-yr net <b>{html_money(kpi['net_3yr'])}</b> · \"\n",
" f\"NPV@{DISCOUNT_RATE:.0%} <b>{html_money(kpi['npv'])}</b> · \"\n",
" f\"payback <b>{kpi['payback']}</b>\"))\n",
"tei_layout(fig, \"Business case vs doing nothing\",\n",
" \"Baseline-relative: net = benefits (programme cost baseline)\")\n",
"fig.show()"
]
},
{
"cell_type": "markdown",
"id": "cell-8",
"metadata": {},
"source": [
"<a id=\"section-3\"></a>\n",
"\n",
"## 3 · Verification & assertions\n",
"\n",
"Engine pins use **explicit default arguments**, so the gate tests `studylib`, not the\n",
"current widget state; live-state checks only run when the inputs sit at their defaults.\n",
"This cell must pass under headless `nbconvert --execute` — it is the study's smoke test.\n",
"Output renders backstage only."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "cell-9",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-08T16:36:52.254794Z",
"iopub.status.busy": "2026-07-08T16:36:52.254464Z",
"iopub.status.idle": "2026-07-08T16:36:52.262674Z",
"shell.execute_reply": "2026-07-08T16:36:52.261927Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"All assertions passed.\n",
" net 3-yr $400K · payback 32 months (~Aug 2028)\n"
]
}
],
"source": [
"def _approx(got, want, tol=0.5):\n",
" assert abs(got - want) <= tol, f\"got {got:,.2f}, want {want:,.2f}\"\n",
"\n",
"\n",
"# Engine pins — explicit defaults, independent of widget state\n",
"_approx(anchor(\"platform_annual\"), 500_000) # signed overlay\n",
"_approx(ANCHOR_VERBATIM[\"platform_annual\"], 600_000) # vendor record intact\n",
"_p = platform_costs_by_year() # verbatim rate, 6-mo ramp\n",
"_approx(_p[2026], 300_000)\n",
"_approx(_p[2027], 600_000)\n",
"_b = benefits_by_year()\n",
"_approx(sum(_b.values()), anchor(\"benefit_3yr\"))\n",
"_approx(_b[2026], 0) # nothing lands in year 1\n",
"\n",
"# Default-flow pins (contracted frame at engine defaults)\n",
"_pc = platform_costs_by_year(annual=anchor(\"platform_annual\"))\n",
"_tot = {y: current_costs_by_year()[y] + _pc[y] + services_by_year()[y] for y in YEARS}\n",
"_, _net = case_flows(_tot, _b)\n",
"_approx(sum(_net.values()), 400_000)\n",
"assert payback_label(_net) == \"32 months (~Aug 2028)\"\n",
"\n",
"# Live state — only checked when the widgets sit at their defaults\n",
"_at_default = (PLATFORM_ANNUAL == round(anchor(\"platform_annual\"))\n",
" and RAMP_MONTHS == DEFAULT_RAMP_MONTHS)\n",
"if _at_default:\n",
" _approx(kpi[\"net_3yr\"], 400_000)\n",
"for y in YEARS:\n",
" _approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL))\n",
"\n",
"backstage(\"All assertions passed.\")\n",
"backstage(f\" net 3-yr {money(kpi['net_3yr'])} · payback {kpi['payback']}\")"
]
},
{
"cell_type": "markdown",
"id": "cell-10",
"metadata": {},
"source": [
"<a id=\"section-4\"></a>\n",
"\n",
"## 4 · Data appendix — for the machines\n",
"\n",
"Everything above, dumped as markdown tables plus one JSON block of model state, so the\n",
"exported report is complete LLM input without re-running anything. The dump renders\n",
"**backstage** (JupyterLab and the exports) and stays hidden in the Mercury app."
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "cell-11",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-08T16:36:52.264962Z",
"iopub.status.busy": "2026-07-08T16:36:52.264712Z",
"iopub.status.idle": "2026-07-08T16:36:52.284958Z",
"shell.execute_reply": "2026-07-08T16:36:52.284097Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"#### Cost stack ($)\n",
"\n",
"| | 2026 | 2027 | 2028 | 3-yr |\n",
"|:------------------------------------------|----------:|----------:|--------:|----------:|\n",
"| Platform (contracted, ramp-adjusted) | 250,000 | 500,000 | 500,000 | 1,250,000 |\n",
"| Services (year 1) | 250,000 | 0 | 0 | 250,000 |\n",
"| Existing platform (term-contract run-off) | 1,000,000 | 1,000,000 | 0 | 2,000,000 |\n",
"\n",
"#### Business-case flows vs do-nothing baseline ($)\n",
"\n",
"| | 2026 | 2027 | 2028 |\n",
"|:-----------------|----------:|----------:|----------:|\n",
"| programme cost | 1,500,000 | 1,500,000 | 500,000 |\n",
"| incremental cost | 500,000 | 500,000 | -500,000 |\n",
"| net | -500,000 | -200,000 | 1,100,000 |\n",
"\n",
"#### KPIs — vendor frame vs contracted\n",
"\n",
"| | Vendor frame ($600K/yr) | Contracted ($500K/yr) |\n",
"|:----------------------|:--------------------------|:------------------------|\n",
"| 3-yr benefits | $900K | $900K |\n",
"| 3-yr incremental cost | $750K | $500K |\n",
"| 3-yr net | $150K | $400K |\n",
"| ROI | 20% | 80% |\n",
"| NPV @ 10% | $3K | $207K |\n",
"| Payback | 35 months (~Nov 2028) | 32 months (~Aug 2028) |\n",
"\n",
"#### Model state (JSON)\n",
"\n",
"```json\n",
"{\n",
" \"scenario\": \"TEMPLATE \\u2014 replace with the study's one-line scenario\",\n",
" \"benefit_by_year\": {\n",
" \"2026\": 0,\n",
" \"2027\": 300000,\n",
" \"2028\": 600000\n",
" },\n",
" \"cost_by_year\": {\n",
" \"2026\": 1500000,\n",
" \"2027\": 1500000,\n",
" \"2028\": 500000\n",
" },\n",
" \"net_by_year\": {\n",
" \"2026\": -500000,\n",
" \"2027\": -200000,\n",
" \"2028\": 1100000\n",
" },\n",
" \"kpis\": {\n",
" \"benefits_3yr\": 900000.0,\n",
" \"incremental_cost_3yr\": 500000.0,\n",
" \"net_3yr\": 400000.0,\n",
" \"roi\": 0.8,\n",
" \"npv\": 206611.5702,\n",
" \"discount_rate\": 0.1,\n",
" \"payback\": \"32 months (~Aug 2028)\"\n",
" },\n",
" \"kpis_vendor_frame\": {\n",
" \"benefits_3yr\": 900000.0,\n",
" \"incremental_cost_3yr\": 750000.0,\n",
" \"net_3yr\": 150000.0,\n",
" \"roi\": 0.2,\n",
" \"npv\": 3380.9166,\n",
" \"discount_rate\": 0.1,\n",
" \"payback\": \"35 months (~Nov 2028)\"\n",
" },\n",
" \"assumptions\": {\n",
" \"platform_annual\": 500000,\n",
" \"vendor_platform_annual\": 600000,\n",
" \"ramp_months\": 6,\n",
" \"discount_rate\": 0.1,\n",
" \"baseline_annual\": 1000000\n",
" }\n",
"}\n",
"```\n"
]
}
],
"source": [
"# ── Data appendix — LLM-readable dump of every model output ──────────\n",
"# Renders backstage only (JupyterLab / nbconvert exports) — hidden on\n",
"# the Mercury stage, where the narrative and figures carry the story.\n",
"import json as _json\n",
"\n",
"\n",
"def _section(title, df, **kw):\n",
" backstage(f\"\\n#### {title}\\n\")\n",
" backstage(df.to_markdown(floatfmt=\",.0f\", **kw))\n",
"\n",
"\n",
"_section(\"Cost stack ($)\", study_costs)\n",
"_section(\"Business-case flows vs do-nothing baseline ($)\",\n",
" pd.DataFrame({\"programme cost\": total_by_year,\n",
" \"incremental cost\": inc, \"net\": net_by}).T[YEARS])\n",
"_section(\"KPIs — vendor frame vs contracted\", kpis_fmt)\n",
"\n",
"backstage(\"\\n#### Model state (JSON)\\n\")\n",
"backstage(\"```json\")\n",
"backstage(_json.dumps({\n",
" \"scenario\": \"TEMPLATE — replace with the study's one-line scenario\",\n",
" \"benefit_by_year\": {str(y): round(ben_by_year[y]) for y in YEARS},\n",
" \"cost_by_year\": {str(y): round(total_by_year[y]) for y in YEARS},\n",
" \"net_by_year\": {str(y): round(net_by[y]) for y in YEARS},\n",
" \"kpis\": {k: (round(v, 4) if isinstance(v, float) else v)\n",
" for k, v in kpi.items()},\n",
" \"kpis_vendor_frame\": {k: (round(v, 4) if isinstance(v, float) else v)\n",
" for k, v in kpi_v.items()},\n",
" \"assumptions\": {\n",
" \"platform_annual\": round(PLATFORM_ANNUAL),\n",
" \"vendor_platform_annual\": ANCHOR_VERBATIM[\"platform_annual\"],\n",
" \"ramp_months\": RAMP_MONTHS,\n",
" \"discount_rate\": DISCOUNT_RATE,\n",
" \"baseline_annual\": round(BASELINE_ANNUAL),\n",
" },\n",
"}, indent=2))\n",
"backstage(\"```\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
},
"widgets": {
"application/vnd.jupyter.widget-state+json": {
"state": {
"2cd79c44365840a4af77e279bf99a67f": {
"model_module": "@jupyter-widgets/base",
"model_module_version": "2.0.0",
"model_name": "LayoutModel",
"state": {
"_model_module": "@jupyter-widgets/base",
"_model_module_version": "2.0.0",
"_model_name": "LayoutModel",
"_view_count": null,
"_view_module": "@jupyter-widgets/base",
"_view_module_version": "2.0.0",
"_view_name": "LayoutView",
"align_content": null,
"align_items": null,
"align_self": null,
"border_bottom": null,
"border_left": null,
"border_right": null,
"border_top": null,
"bottom": null,
"display": null,
"flex": null,
"flex_flow": null,
"grid_area": null,
"grid_auto_columns": null,
"grid_auto_flow": null,
"grid_auto_rows": null,
"grid_column": null,
"grid_gap": null,
"grid_row": null,
"grid_template_areas": null,
"grid_template_columns": null,
"grid_template_rows": null,
"height": null,
"justify_content": null,
"justify_items": null,
"left": null,
"margin": null,
"max_height": null,
"max_width": null,
"min_height": null,
"min_width": null,
"object_fit": null,
"object_position": null,
"order": null,
"overflow": null,
"padding": null,
"right": null,
"top": null,
"visibility": null,
"width": null
}
},
"56c2f21d17cf40009b6d4bcee7bcdd9a": {
"model_module": "anywidget",
"model_module_version": "~0.11.*",
"model_name": "AnyModel",
"state": {
"_anywidget_id": "mercury.select.SelectWidget",
"_css": "\n .mljar-select-container {\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n }\n\n .mljar-select-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: none;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n margin-top: 6px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ",
"_dom_classes": [],
"_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n container.appendChild(dropdown);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target)) {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n isEditing = false;\n setOpen(false);\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n document.removeEventListener(\"click\", handleDocumentClick);\n };\n }\n export default { render };\n ",
"_model_module": "anywidget",
"_model_module_version": "~0.11.*",
"_model_name": "AnyModel",
"_view_count": null,
"_view_module": "anywidget",
"_view_module_version": "~0.11.*",
"_view_name": "AnyView",
"cell_id": "",
"choices": [
"10% (vendor)",
"8% (treasury)"
],
"disabled": false,
"hidden": false,
"label": "NPV discount rate",
"layout": "IPY_MODEL_2cd79c44365840a4af77e279bf99a67f",
"layout_path": null,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": "10% (vendor)"
}
},
"60e56739936e4f2ab8ce7a25fe31c092": {
"model_module": "anywidget",
"model_module_version": "~0.11.*",
"model_name": "AnyModel",
"state": {
"_anywidget_id": "mercury.number.NumberInputWidget",
"_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
"_dom_classes": [],
"_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
"_model_module": "anywidget",
"_model_module_version": "~0.11.*",
"_model_name": "AnyModel",
"_view_count": null,
"_view_module": "anywidget",
"_view_module_version": "~0.11.*",
"_view_name": "AnyView",
"cell_id": "",
"disabled": false,
"hidden": false,
"label": "Ramp — billing-free months",
"layout": "IPY_MODEL_e41731dbca994eeeb59b0b6edcfc9276",
"layout_path": null,
"max": 24.0,
"min": 0.0,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"step": 3.0,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": 6.0
}
},
"924aaf1b9a7244388d4fdc29e0f544e2": {
"model_module": "anywidget",
"model_module_version": "~0.11.*",
"model_name": "AnyModel",
"state": {
"_anywidget_id": "mercury.number.NumberInputWidget",
"_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
"_dom_classes": [],
"_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
"_model_module": "anywidget",
"_model_module_version": "~0.11.*",
"_model_name": "AnyModel",
"_view_count": null,
"_view_module": "anywidget",
"_view_module_version": "~0.11.*",
"_view_name": "AnyView",
"cell_id": "",
"disabled": false,
"hidden": false,
"label": "Platform run-rate ($/yr) — contracted",
"layout": "IPY_MODEL_dab8383f122d4e85b850290ec384a030",
"layout_path": null,
"max": 10000000.0,
"min": 0.0,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"step": 25000.0,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": 500000.0
}
},
"dab8383f122d4e85b850290ec384a030": {
"model_module": "@jupyter-widgets/base",
"model_module_version": "2.0.0",
"model_name": "LayoutModel",
"state": {
"_model_module": "@jupyter-widgets/base",
"_model_module_version": "2.0.0",
"_model_name": "LayoutModel",
"_view_count": null,
"_view_module": "@jupyter-widgets/base",
"_view_module_version": "2.0.0",
"_view_name": "LayoutView",
"align_content": null,
"align_items": null,
"align_self": null,
"border_bottom": null,
"border_left": null,
"border_right": null,
"border_top": null,
"bottom": null,
"display": null,
"flex": null,
"flex_flow": null,
"grid_area": null,
"grid_auto_columns": null,
"grid_auto_flow": null,
"grid_auto_rows": null,
"grid_column": null,
"grid_gap": null,
"grid_row": null,
"grid_template_areas": null,
"grid_template_columns": null,
"grid_template_rows": null,
"height": null,
"justify_content": null,
"justify_items": null,
"left": null,
"margin": null,
"max_height": null,
"max_width": null,
"min_height": null,
"min_width": null,
"object_fit": null,
"object_position": null,
"order": null,
"overflow": null,
"padding": null,
"right": null,
"top": null,
"visibility": null,
"width": null
}
},
"e41731dbca994eeeb59b0b6edcfc9276": {
"model_module": "@jupyter-widgets/base",
"model_module_version": "2.0.0",
"model_name": "LayoutModel",
"state": {
"_model_module": "@jupyter-widgets/base",
"_model_module_version": "2.0.0",
"_model_name": "LayoutModel",
"_view_count": null,
"_view_module": "@jupyter-widgets/base",
"_view_module_version": "2.0.0",
"_view_name": "LayoutView",
"align_content": null,
"align_items": null,
"align_self": null,
"border_bottom": null,
"border_left": null,
"border_right": null,
"border_top": null,
"bottom": null,
"display": null,
"flex": null,
"flex_flow": null,
"grid_area": null,
"grid_auto_columns": null,
"grid_auto_flow": null,
"grid_auto_rows": null,
"grid_column": null,
"grid_gap": null,
"grid_row": null,
"grid_template_areas": null,
"grid_template_columns": null,
"grid_template_rows": null,
"height": null,
"justify_content": null,
"justify_items": null,
"left": null,
"margin": null,
"max_height": null,
"max_width": null,
"min_height": null,
"min_width": null,
"object_fit": null,
"object_position": null,
"order": null,
"overflow": null,
"padding": null,
"right": null,
"top": null,
"visibility": null,
"width": null
}
}
},
"version_major": 2,
"version_minor": 0
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}