{
"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 2026โ2028\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\"{title}\"\n",
" if subtitle:\n",
" t += f\"
{subtitle}\"\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}\" + name + \"\")\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}\" + name + \"\")\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": [
"\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": [
""
]
},
"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": [
""
]
},
"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": [
""
]
},
"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": [
"\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": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" 2026 | \n",
" 2027 | \n",
" 2028 | \n",
" 3-yr | \n",
"
\n",
" \n",
" \n",
" \n",
" | Platform (contracted, ramp-adjusted) | \n",
" 250,000 | \n",
" 500,000 | \n",
" 500,000 | \n",
" 1,250,000 | \n",
"
\n",
" \n",
" | Services (year 1) | \n",
" 250,000 | \n",
" 0 | \n",
" 0 | \n",
" 250,000 | \n",
"
\n",
" \n",
" | Existing platform (term-contract run-off) | \n",
" 1,000,000 | \n",
" 1,000,000 | \n",
" 0 | \n",
" 2,000,000 | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Vendor frame ($600K/yr) | \n",
" Contracted ($500K/yr) | \n",
"
\n",
" \n",
" \n",
" \n",
" | 3-yr benefits | \n",
" $900K | \n",
" $900K | \n",
"
\n",
" \n",
" | 3-yr incremental cost | \n",
" $750K | \n",
" $500K | \n",
"
\n",
" \n",
" | 3-yr net | \n",
" $150K | \n",
" $400K | \n",
"
\n",
" \n",
" | ROI | \n",
" 20% | \n",
" 80% | \n",
"
\n",
" \n",
" | NPV @ 10% | \n",
" $3K | \n",
" $207K | \n",
"
\n",
" \n",
" | Payback | \n",
" 35 months (~Nov 2028) | \n",
" 32 months (~Aug 2028) | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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}Benefits",
"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}Incremental cost vs $1.0M/yr baseline",
"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}Cumulative net",
"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 $400K ยท NPV@10% $207K ยท payback 32 months (~Aug 2028)",
"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": "Business case vs doing nothing
Baseline-relative: net = benefits โ (programme cost โ baseline)",
"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 {html_money(kpi['net_3yr'])} ยท \"\n",
" f\"NPV@{DISCOUNT_RATE:.0%} {html_money(kpi['npv'])} ยท \"\n",
" f\"payback {kpi['payback']}\"))\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": [
"\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": [
"\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
}