diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index facc9e1c..49ef660e 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -22,24 +22,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
-
- name: Install uv and Python
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'
-
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
-
- name: Install project and docs dependencies
run: uv sync --group docs
-
- name: Build Great Docs site
run: uv run great-docs build
-
- name: Export Great Tables performance table demo
run: uv run marimo export html --no-include-code examples/performance_table_demo.py -o great-docs/_site/performance-table-demo.html
-
- name: Export Reactable performance table demo
run: |
uv run quarto render examples/performance_table_reactable.qmd --output performance-table-reactable.html
@@ -47,7 +41,10 @@ jobs:
grep -q 'Real Positive' performance-table-reactable.html
mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html
cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files
-
+ - name: Generate summary report demo
+ run: |
+ uv run python examples/summary_report_demo.py
+ mv summary-report-demo.html great-docs/_site/summary-report-demo.html
- name: Publish documentation
uses: JamesIves/github-pages-deploy-action@v4
with:
@@ -63,29 +60,23 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
-
- name: Install uv and Python
if: github.event.action != 'closed'
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'
-
- name: Set up Quarto
if: github.event.action != 'closed'
uses: quarto-dev/quarto-actions/setup@v2
-
- name: Install project and docs dependencies
if: github.event.action != 'closed'
run: uv sync --group docs
-
- name: Build Great Docs preview
if: github.event.action != 'closed'
run: uv run great-docs build
-
- name: Export Great Tables performance table demo
if: github.event.action != 'closed'
run: uv run marimo export html --no-include-code examples/performance_table_demo.py -o great-docs/_site/performance-table-demo.html
-
- name: Export Reactable performance table demo
if: github.event.action != 'closed'
run: |
@@ -94,7 +85,50 @@ jobs:
grep -q 'Real Positive' performance-table-reactable.html
mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html
cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files
-
+ - name: Generate Python summary report demo
+ if: github.event.action != 'closed'
+ run: |
+ uv run python examples/summary_report_demo.py
+ test -s summary-report-demo.html
+ test -s summary-report-reference-data.csv
+ mv summary-report-demo.html great-docs/_site/summary-report-demo.html
+ - name: Set up R for reference report
+ if: github.event.action != 'closed'
+ uses: r-lib/actions/setup-r@v2
+ - name: Install R reference report dependencies
+ if: github.event.action != 'closed'
+ uses: r-lib/actions/setup-r-dependencies@v2
+ with:
+ packages: |
+ any::rmarkdown
+ any::knitr
+ github::uriahf/rtichoke
+ - name: Set up Pandoc for R Markdown
+ if: github.event.action != 'closed'
+ uses: r-lib/actions/setup-pandoc@v2
+ - name: Render canonical R summary report
+ if: github.event.action != 'closed'
+ env:
+ GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
+ shell: Rscript {0}
+ run: |
+ dat <- read.csv("summary-report-reference-data.csv", check.names = FALSE)
+ reals <- dat[["reals"]]
+ probs <- list(
+ "Model A" = dat[["Model A"]],
+ "Model B" = dat[["Model B"]]
+ )
+ rtichoke::create_summary_report(
+ probs = probs,
+ reals = list(reals),
+ output_file = "summary-report-r-reference.html",
+ output_dir = file.path(getwd(), "great-docs", "_site")
+ )
+ stopifnot(file.info(file.path("great-docs", "_site", "summary-report-r-reference.html"))$size > 0)
+ - name: Record report sizes
+ if: github.event.action != 'closed'
+ run: |
+ wc -c great-docs/_site/summary-report-demo.html great-docs/_site/summary-report-r-reference.html | tee great-docs/_site/summary-report-sizes.txt
- name: Deploy PR preview
uses: rossjrw/pr-preview-action@v1
with:
@@ -102,3 +136,15 @@ jobs:
preview-branch: gh-pages
umbrella-dir: pr-preview
action: auto
+ - name: Verify report is in deployed documentation preview
+ if: github.event.action != 'closed'
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+ git fetch origin gh-pages
+ REPORT_PATH="pr-preview/pr-${PR_NUMBER}/summary-report-demo.html"
+ git cat-file -e "origin/gh-pages:${REPORT_PATH}"
+ echo "Verified ${REPORT_PATH} on gh-pages"
+ echo "### Summary report preview" >> "$GITHUB_STEP_SUMMARY"
+ echo "https://${GITHUB_REPOSITORY_OWNER}.github.io/${GITHUB_REPOSITORY#*/}/${REPORT_PATH}" >> "$GITHUB_STEP_SUMMARY"
diff --git a/SUMMARY_REPORT_POC.md b/SUMMARY_REPORT_POC.md
new file mode 100644
index 00000000..34023fe5
--- /dev/null
+++ b/SUMMARY_REPORT_POC.md
@@ -0,0 +1,5 @@
+# D3 summary report proof of concept
+
+This branch replaces the old R-backend `create_summary_report()` stub with a native Python binary report. It prepares the Polars performance table once and serializes only the columns needed by five D3 panels. The PR preview workflow publishes the generated example as `summary-report-demo.html`.
+
+The first preview deliberately uses the D3 CDN so the architecture and interaction can be reviewed before vendoring D3 into the package. A production version should bundle D3 to make the output genuinely self-contained.
diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 00000000..4bb56bfd
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,9 @@
+# Benchmarks
+
+Run the summary-report preparation benchmark with:
+
+```bash
+uv run python benchmarks/benchmark_summary_report.py
+```
+
+It compares the current repeated preparation pattern for five report panels with preparing the shared performance table once.
diff --git a/benchmarks/benchmark_summary_report.py b/benchmarks/benchmark_summary_report.py
new file mode 100644
index 00000000..61e2c407
--- /dev/null
+++ b/benchmarks/benchmark_summary_report.py
@@ -0,0 +1,30 @@
+"""Small local benchmark for repeated vs shared performance preparation."""
+
+from time import perf_counter
+
+import numpy as np
+
+from rtichoke import prepare_performance_data
+
+
+def run(n: int = 100_000, repeats: int = 5) -> None:
+ rng = np.random.default_rng(2026)
+ reals = rng.binomial(1, 0.25, n)
+ probs = {"model": np.clip(0.1 + 0.65 * reals + rng.normal(0, 0.18, n), 0, 1)}
+
+ start = perf_counter()
+ for _ in range(repeats):
+ prepare_performance_data(probs, reals)
+ repeated = perf_counter() - start
+
+ start = perf_counter()
+ performance_data = prepare_performance_data(probs, reals)
+ for _ in range(repeats):
+ _ = performance_data
+ shared = perf_counter() - start
+
+ print(f"n={n:,}; repeated={repeated:.3f}s; shared={shared:.3f}s; ratio={repeated/shared:.2f}x")
+
+
+if __name__ == "__main__":
+ run()
diff --git a/examples/summary_report_demo.py b/examples/summary_report_demo.py
new file mode 100644
index 00000000..47994bee
--- /dev/null
+++ b/examples/summary_report_demo.py
@@ -0,0 +1,26 @@
+"""Generate the summary-report proof of concept used by PR previews."""
+
+import csv
+
+import numpy as np
+
+from rtichoke import create_summary_report
+
+rng = np.random.default_rng(2026)
+n = 800
+signal = rng.normal(size=n)
+reals = rng.binomial(1, 1 / (1 + np.exp(-signal)))
+
+probs = {
+ "Model A": np.clip(1 / (1 + np.exp(-(0.9 * signal + rng.normal(0, 0.55, n)))), 0.001, 0.999),
+ "Model B": np.clip(1 / (1 + np.exp(-(0.6 * signal + rng.normal(0, 0.85, n)))), 0.001, 0.999),
+}
+
+# The PR preview renders the canonical R report from this exact dataset. Using
+# one serialized dataset avoids NumPy/R RNG differences obscuring visual parity.
+with open("summary-report-reference-data.csv", "w", newline="", encoding="utf-8") as f:
+ writer = csv.writer(f)
+ writer.writerow(["reals", "Model A", "Model B"])
+ writer.writerows(zip(reals, probs["Model A"], probs["Model B"], strict=True))
+
+create_summary_report(probs, reals, output_file="summary-report-demo.html")
diff --git a/scripts/reactable_embed_spike.py b/scripts/reactable_embed_spike.py
new file mode 100644
index 00000000..915e991f
--- /dev/null
+++ b/scripts/reactable_embed_spike.py
@@ -0,0 +1,56 @@
+"""Spike: export rtichoke's real Reactable performance table to standalone HTML.
+
+This deliberately avoids Quarto/Jupyter as report assemblers. It uses the
+ipywidgets static embed protocol and the existing rtichoke Reactable renderer.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+from ipywidgets.embed import dependency_state, embed_data
+
+from rtichoke.performance_data.performance_data import prepare_performance_data
+from rtichoke.performance_table_reactable import render_performance_table_reactable
+
+
+def main() -> None:
+ reals = np.array([0, 0, 0, 0, 1, 1, 1, 1, 1, 1])
+ probs = {
+ "Model A": np.array([0.05, 0.10, 0.15, 0.25, 0.35, 0.50, 0.60, 0.72, 0.82, 0.93]),
+ "Model B": np.array([0.10, 0.20, 0.30, 0.35, 0.40, 0.45, 0.55, 0.65, 0.75, 0.85]),
+ }
+ performance_data = prepare_performance_data(
+ probs=probs,
+ reals=reals,
+ stratified_by=("probability_threshold",),
+ by=0.05,
+ )
+ table = render_performance_table_reactable(
+ performance_data=performance_data,
+ probs=probs,
+ reals=reals,
+ stratified_by="probability_threshold",
+ )
+ widget = table.to_widget()
+ data = embed_data(views=[widget], state=dependency_state([widget]))
+
+ html = f"""
+
Reactable standalone spike
+
+
+
+
+rtichoke Reactable standalone spike
+No Quarto or running Jupyter kernel is used to view this page.
+
+"""
+ out = Path("reactable-standalone-spike.html")
+ out.write_text(html, encoding="utf-8")
+ print(f"Wrote {out} ({out.stat().st_size / 1024:.1f} KiB HTML payload)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/rtichoke/__init__.py b/src/rtichoke/__init__.py
index 5e8b0855..e382ddcc 100644
--- a/src/rtichoke/__init__.py
+++ b/src/rtichoke/__init__.py
@@ -57,7 +57,7 @@
render_performance_table as render_performance_table,
)
-from rtichoke.summary_report.summary_report import (
+from rtichoke.summary_report.summary_report_plotly import (
create_summary_report as create_summary_report,
)
diff --git a/src/rtichoke/summary_report/calibration_renderer.README.md b/src/rtichoke/summary_report/calibration_renderer.README.md
new file mode 100644
index 00000000..a11ab61d
--- /dev/null
+++ b/src/rtichoke/summary_report/calibration_renderer.README.md
@@ -0,0 +1,7 @@
+# Calibration renderer
+
+`calibration_renderer.js` is the isolated D3 implementation used for calibration-parity work against the R `create_summary_report()` reference.
+
+The extraction keeps calibration geometry, histogram behavior, axes, legend, and hover behavior reviewable without mixing changes into discrimination, utility, or performance-table rendering.
+
+The next wiring step is to inject `calibration_renderer_source()` into the generated self-contained HTML in place of the inline `calibration()` implementation. Once wired, hover and visual parity changes should be made only in this renderer.
diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js
new file mode 100644
index 00000000..3ba13941
--- /dev/null
+++ b/src/rtichoke/summary_report/calibration_renderer.js
@@ -0,0 +1,107 @@
+/* Calibration-only D3 renderer for the lightweight summary report.
+ * Mirrors the actual R Plotly calibration composition: a 550px figure,
+ * 80/20 shared-x subplot, markers+lines for discrete calibration, line-only
+ * smooth calibration, overlaid 0.01-wide histogram bars and horizontal legend.
+ */
+function calibration(type, sel) {
+ const c = R.calibration;
+ const card = d3.select(sel);
+ card.selectAll("*").remove();
+
+ const W = 550, H = 550;
+ const X0 = 60, X1 = 540;
+ const MAIN_TOP = 55, MAIN_BOTTOM = 409.9;
+ const HIST_TOP = 428.1, HIST_BOTTOM = 510;
+ const x = d3.scaleLinear().domain(c.ranges.xaxis).range([X0, X1]);
+ const y = d3.scaleLinear().domain(c.ranges.yaxis).range([MAIN_BOTTOM, MAIN_TOP]);
+ const histMax = d3.max(c.histogram, d => +d.counts) || 1;
+ const yHist = d3.scaleLinear().domain([0, histMax]).nice().range([HIST_BOTTOM, HIST_TOP]);
+ const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`);
+
+ const contrastText = color => {
+ const hex = String(color || "#333").replace("#", "");
+ if (!/^[0-9a-f]{6}$/i.test(hex)) return "white";
+ const r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16);
+ return (0.299 * r + 0.587 * g + 0.114 * b) > 170 ? "#222" : "white";
+ };
+ const showTip = (ev, html, color) => {
+ const bg = color || "#333";
+ tip.style("opacity", 1).style("left", (ev.clientX + 10) + "px").style("top", (ev.clientY + 10) + "px")
+ .style("background", bg).style("border", "1px solid " + bg).style("border-radius", "2px")
+ .style("box-shadow", "none").style("padding", "6px 8px")
+ .style("font-family", "Open Sans, verdana, arial, sans-serif").style("font-size", "12px")
+ .style("line-height", "15px").style("color", contrastText(bg)).html(String(html || ""));
+ };
+ const hideTip = () => tip.style("opacity", 0);
+ const groupOf = d => String(d.reference_group || "");
+ const hoverColor = d => groupOf(d) === "reference_line" ? "#bebebe" : (c.colors[groupOf(d)] || "#777");
+
+ const defs = svg.append("defs");
+ defs.append("clipPath").attr("id", `main-${type}`).append("rect").attr("x", X0).attr("y", MAIN_TOP).attr("width", X1-X0).attr("height", MAIN_BOTTOM-MAIN_TOP);
+ defs.append("clipPath").attr("id", `hist-${type}`).append("rect").attr("x", X0).attr("y", HIST_TOP).attr("width", X1-X0).attr("height", HIST_BOTTOM-HIST_TOP);
+
+ // Plotly puts the calibration legend horizontally above the main panel and
+ // suppresses it entirely for a single model.
+ if (c.groups.length > 1) {
+ const lg = svg.append("g").attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 12);
+ const itemW = Math.max(82, 46 + d3.max(c.groups, g => String(g).length) * 7);
+ const total = itemW * c.groups.length, start = W/2-total/2;
+ c.groups.forEach((g,i) => {
+ const q=lg.append("g").attr("transform",`translate(${start+i*itemW},24)`);
+ q.append("line").attr("x1",5).attr("x2",35).attr("stroke",c.colors[g]).attr("stroke-width",2);
+ if(type === "discrete") q.append("circle").attr("cx",20).attr("cy",0).attr("r",5).attr("fill",c.colors[g]).attr("stroke-width",0);
+ q.append("text").attr("x",40).attr("y",4).attr("fill","#444").text(g);
+ });
+ }
+
+ const styleAxis = axis => {
+ axis.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");
+ axis.select(".domain").attr("stroke","#444");
+ axis.selectAll(".tick line").attr("stroke","#444");
+ };
+ const ay=svg.append("g").attr("class","axis").attr("transform",`translate(${X0},0)`).call(d3.axisLeft(y).ticks(5));
+ const ah=svg.append("g").attr("class","axis").attr("transform",`translate(${X0},0)`).call(d3.axisLeft(yHist).ticks(4));
+ const ax=svg.append("g").attr("class","axis").attr("transform",`translate(0,${HIST_BOTTOM})`).call(d3.axisBottom(x).ticks(5));
+ styleAxis(ay); styleAxis(ah); styleAxis(ax);
+ svg.append("text").attr("class","axis-label").attr("x",(X0+X1)/2).attr("y",548).attr("text-anchor","middle").text("Predicted");
+ svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(MAIN_TOP+MAIN_BOTTOM)/2).attr("y",18).attr("text-anchor","middle").text("Observed");
+
+ const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y));
+ const main=svg.append("g").attr("clip-path",`url(#main-${type})`);
+ // Plotly's dash="dot" is visually closer to a short round dot pattern than
+ // the earlier equal 3/3 dash pattern.
+ main.append("path").datum(c.reference).attr("fill","none").attr("stroke","#bebebe").attr("stroke-width",2)
+ .attr("stroke-dasharray","2,4").attr("stroke-linecap","round").attr("d",line);
+
+ const dat=type === "smooth" ? c.smooth : c.deciles;
+ c.groups.forEach(g => {
+ const a=dat.filter(d=>String(d.reference_group)===g);
+ main.append("path").datum(a).attr("fill","none").attr("stroke",c.colors[g]).attr("stroke-width",2)
+ .attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line);
+ if(type === "discrete") main.selectAll(null).data(a).enter().append("circle")
+ .attr("cx",d=>x(+d.x)).attr("cy",d=>y(+d.y)).attr("r",5)
+ .attr("fill",c.colors[g]).attr("stroke",c.colors[g]).attr("stroke-width",0)
+ .attr("shape-rendering","geometricPrecision");
+ });
+
+ // R uses plotly::add_bars(width=.01, barmode="overlay") with opacity
+ // 1/n_groups. Use the exact bin edges rather than a hand-tuned pixel width.
+ const hist=svg.append("g").attr("clip-path",`url(#hist-${type})`), opacity=1/Math.max(1,c.groups.length);
+ c.histogram.forEach(d => {
+ const mid=+d.mids,left=x(mid-.005),right=x(mid+.005);
+ hist.append("rect").attr("x",left).attr("width",Math.max(0,right-left)).attr("y",yHist(+d.counts)).attr("height",HIST_BOTTOM-yHist(+d.counts))
+ .attr("fill",c.colors[String(d.reference_group)]||"#777").attr("opacity",opacity).attr("stroke","none")
+ .on("mousemove",ev=>showTip(ev,d.text,hoverColor(d))).on("mouseleave",hideTip);
+ });
+
+ // Plotly scatter hover is point based. Keep the nearest-point interaction,
+ // but use a smaller capture radius so the tooltip does not jump to a remote
+ // calibration point while moving through empty plot space.
+ const hoverData=dat.concat(c.reference);
+ svg.append("rect").attr("x",X0).attr("y",MAIN_TOP).attr("width",X1-X0).attr("height",MAIN_BOTTOM-MAIN_TOP).attr("fill","transparent")
+ .on("mousemove",ev=>{
+ const [mx,my]=d3.pointer(ev);let best=null,dist=Infinity;
+ hoverData.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd str:
+ """Return the lightweight D3 renderer sources embedded in the report."""
+ root = Path(__file__).parent
+ assets = (
+ root / "calibration_renderer.js",
+ root / "performance_table_renderer.js",
+ )
+ return "\n".join(path.read_text(encoding="utf-8") for path in assets)
diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js
new file mode 100644
index 00000000..fd3a2cd5
--- /dev/null
+++ b/src/rtichoke/summary_report/curve_renderer.js
@@ -0,0 +1,37 @@
+/* D3 renderer for performance and decision curves in the lightweight report.
+ * Mirrors the R Plotly geometry used by create_summary_report(): a 500px-wide,
+ * 550px-high widget with the animation slider inside the widget and no internal
+ * title. ROC/Lift/etc. are supplied by the tab headings.
+ */
+function drawRtichokeCurve(s, sel, strat) {
+ const card=d3.select(sel); card.selectAll("*").remove();
+ card.style("width","500px").style("height","550px").style("max-width","100%").style("margin-left","0").style("margin-right","0").style("position","relative");
+ const W=500,H=550,m={top:25,right:10,bottom:120,left:60};
+ const svg=card.append("svg").style("width","500px").style("height","550px").style("max-width","100%").style("margin","0").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]);
+ const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y));
+ const styleAxis=a=>{a.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");a.select(".domain").attr("stroke","#444");a.selectAll(".tick line").attr("stroke","#444")};
+ const xa=svg.append("g").attr("class","axis").attr("transform",`translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)),ya=svg.append("g").attr("class","axis").attr("transform",`translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); styleAxis(xa);styleAxis(ya);
+ svg.append("text").attr("class","axis-label").attr("x",(m.left+W-m.right)/2).attr("y",H-m.bottom+42).attr("text-anchor","middle").text(s.x_label);
+ svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(m.top+H-m.bottom)/2).attr("y",18).attr("text-anchor","middle").text(s.y_label);
+ const strategyColor=g=>{const k=String(g||"").toLowerCase();if(k==="treat_none")return "#808080";return s.colors[g]||"#BEBEBE"};
+ const traces=[];
+ d3.group(s.references,d=>String(d.reference_group)).forEach((a,g)=>{const color=strategyColor(g);svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","2,4").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))});
+ const single=s.groups.length===1;
+ s.groups.forEach(g=>{const a=s.data.filter(d=>String(d.reference_group)===g),color=single?"black":(s.colors[g]||"#000");svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))});
+
+ const strataField=strat==="ppcr"?"ppcr":"chosen_cutoff";
+ const strata=[...new Set(s.data.map(d=>+d[strataField]).filter(Number.isFinite))].sort((a,b)=>a-b);
+ if(strata.length>1){
+ const wrap=card.append("div").attr("class","slider-wrap curve-slider-wrap").style("position","absolute").style("left","60px").style("bottom","12px").style("width","430px").style("max-width","calc(100% - 70px)").style("margin","0");
+ const label=wrap.append("div").attr("class","slider-label curve-slider-label");
+ const prefix=strat==="ppcr"?"Predicted Positives (Rate):":"Prob. Threshold:";
+ const input=wrap.append("input").attr("class","curve-slider").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).node();
+ input.value=strata[0];
+ const update=()=>{const v=+input.value;label.textContent=`${prefix} ${Number.isFinite(v)?v.toFixed(2):""}`};
+ input.addEventListener("input",update);update();
+ }
+
+ const contrast=color=>{const h=String(color||"#333").replace("#","");if(!/^[0-9a-f]{6}$/i.test(h))return "white";const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);return(.299*r+.587*g+.114*b)>170?"#222":"white"};
+ const show=(ev,d)=>tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px").style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)).style("padding","6px 8px").style("border-radius","2px").style("font-family","Open Sans, verdana, arial, sans-serif").style("font-size","12px").style("line-height","15px").html(String(d.text||"")),hide=()=>tip.style("opacity",0);
+ svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent").on("mousemove",ev=>{const[mx,my]=d3.pointer(ev);let best=null,dist=Infinity;traces.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd str:
+ """Return the D3 performance/decision curve renderer source."""
+ return Path(__file__).with_name("curve_renderer.js").read_text(encoding="utf-8")
diff --git a/src/rtichoke/summary_report/microd3.js b/src/rtichoke/summary_report/microd3.js
new file mode 100644
index 00000000..a2cbe6f9
--- /dev/null
+++ b/src/rtichoke/summary_report/microd3.js
@@ -0,0 +1,44 @@
+/* Minimal D3-compatible runtime used by rtichoke summary reports.
+ * Implements only the selection, linear-scale, line, axis, grouping, max and
+ * pointer primitives used by the report renderers. Keeping this tiny runtime
+ * inline makes generated reports self-contained without shipping the full D3
+ * distribution or requiring a CDN at viewing time.
+ */
+(function(global){
+ const SVG='http://www.w3.org/2000/svg';
+ const svgTags=new Set(['svg','g','path','rect','circle','line','text','defs','clipPath']);
+ const create=(parent,tag)=>svgTags.has(tag)||parent.namespaceURI===SVG?document.createElementNS(SVG,tag):document.createElement(tag);
+ class Selection{
+ constructor(nodes,parents=null){this.nodes=(nodes||[]).filter(Boolean);this.parents=parents||[];this._data=null;}
+ node(){return this.nodes[0]||null}
+ append(tag){const out=[];this.nodes.forEach(n=>{const e=create(n,tag);e.__data__=n.__data__;n.appendChild(e);out.push(e)});return new Selection(out,this.nodes)}
+ select(q){return new Selection(this.nodes.map(n=>typeof q==='function'?q.call(n,n.__data__):n.querySelector(q)).filter(Boolean),this.nodes)}
+ selectAll(q){let out=[];this.nodes.forEach(n=>out.push(...(typeof q==='function'?q.call(n,n.__data__):n.querySelectorAll(q))));return new Selection(out,this.nodes)}
+ remove(){this.nodes.forEach(n=>n.remove());return this}
+ attr(k,v){if(arguments.length===1)return this.node()?.getAttribute(k);this.nodes.forEach((n,i)=>{const x=typeof v==='function'?v.call(n,n.__data__,i):v;x==null?n.removeAttribute(k):n.setAttribute(k,x)});return this}
+ style(k,v){if(arguments.length===1)return getComputedStyle(this.node()).getPropertyValue(k);this.nodes.forEach((n,i)=>n.style.setProperty(k,typeof v==='function'?v.call(n,n.__data__,i):v));return this}
+ text(v){this.nodes.forEach((n,i)=>n.textContent=typeof v==='function'?v.call(n,n.__data__,i):v);return this}
+ html(v){this.nodes.forEach((n,i)=>n.innerHTML=typeof v==='function'?v.call(n,n.__data__,i):v);return this}
+ classed(k,v){this.nodes.forEach((n,i)=>n.classList.toggle(k,!!(typeof v==='function'?v.call(n,n.__data__,i):v)));return this}
+ datum(v){if(!arguments.length)return this.node()?.__data__;this.nodes.forEach(n=>n.__data__=v);return this}
+ data(v){this._data=Array.from(v||[]);return this}
+ enter(){return new EnterSelection(this.parents.length?this.parents:(this.nodes[0]?.parentNode?[this.nodes[0].parentNode]:[]),this._data||[])}
+ on(type,fn){this.nodes.forEach(n=>n.addEventListener(type,e=>fn.call(n,e,n.__data__)));return this}
+ call(fn,...args){fn(this,...args);return this}
+ each(fn){this.nodes.forEach((n,i)=>fn.call(n,n.__data__,i,this.nodes));return this}
+ }
+ class EnterSelection{
+ constructor(parents,data){this.parents=parents;this.dataValues=data}
+ append(tag){const out=[],p=this.parents[0];if(!p)return new Selection([]);this.dataValues.forEach(d=>{const e=create(p,tag);e.__data__=d;p.appendChild(e);out.push(e)});return new Selection(out,[p])}
+ }
+ function select(q){return new Selection([typeof q==='string'?document.querySelector(q):q])}
+ function ticks(a,b,count=10){if(!isFinite(a)||!isFinite(b)||a===b)return[a];const span=Math.abs(b-a),raw=span/Math.max(1,count),pow=10**Math.floor(Math.log10(raw)),err=raw/pow,step=(err>=7.5?10:err>=3.5?5:err>=1.5?2:1)*pow,lo=Math.ceil(Math.min(a,b)/step)*step,hi=Math.floor(Math.max(a,b)/step)*step,out=[];for(let x=lo;x<=hi+step*1e-9;x+=step)out.push(+x.toPrecision(12));return a>b?out.reverse():out}
+ function scaleLinear(){let dom=[0,1],ran=[0,1];const s=x=>ran[0]+(x-dom[0])/(dom[1]-dom[0]||1)*(ran[1]-ran[0]);s.domain=function(x){if(!arguments.length)return dom.slice();dom=Array.from(x,Number);return s};s.range=function(x){if(!arguments.length)return ran.slice();ran=Array.from(x,Number);return s};s.copy=()=>scaleLinear().domain(dom).range(ran);s.ticks=(count=10)=>ticks(dom[0],dom[1],count);s.nice=()=>{const ts=ticks(dom[0],dom[1],10);if(ts.length)dom=[Math.min(dom[0],ts[0]),Math.max(dom[1],ts[ts.length-1])];return s};return s}
+ function fmt(x){if(Math.abs(x)>=1000||Math.abs(x)>0&&Math.abs(x)<1e-4)return x.toExponential(0);return String(+x.toFixed(6))}
+ function axis(scale,orient){let count=10;const fn=sel=>{const root=sel.node();if(!root)return;while(root.firstChild)root.removeChild(root.firstChild);const r=scale.range(),vals=scale.ticks(count),horizontal=orient==='bottom',domain=document.createElementNS(SVG,'path');domain.setAttribute('class','domain');domain.setAttribute('fill','none');domain.setAttribute('stroke','currentColor');domain.setAttribute('d',horizontal?`M${r[0]},0H${r[1]}`:`M0,${r[0]}V${r[1]}`);root.appendChild(domain);vals.forEach(v=>{const g=document.createElementNS(SVG,'g');g.setAttribute('class','tick');g.setAttribute('transform',horizontal?`translate(${scale(v)},0)`:`translate(0,${scale(v)})`);const l=document.createElementNS(SVG,'line');l.setAttribute('stroke','currentColor');horizontal?l.setAttribute('y2','6'):l.setAttribute('x2','-6');const t=document.createElementNS(SVG,'text');t.setAttribute('fill','currentColor');t.setAttribute('font-size','10');t.setAttribute('font-family','sans-serif');if(horizontal){t.setAttribute('y','9');t.setAttribute('dy','0.71em');t.setAttribute('text-anchor','middle')}else{t.setAttribute('x','-9');t.setAttribute('dy','0.32em');t.setAttribute('text-anchor','end')}t.textContent=fmt(v);g.append(l,t);root.appendChild(g)})};fn.ticks=n=>(count=n,fn);return fn}
+ function line(){let fx=d=>d[0],fy=d=>d[1],defined=()=>true;const gen=data=>{let out='',started=false;for(const d of data||[]){if(!defined(d)){started=false;continue}const x=fx(d),y=fy(d);if(!isFinite(x)||!isFinite(y)){started=false;continue}out+=(started?'L':'M')+x+','+y;started=true}return out};gen.x=f=>(fx=f,gen);gen.y=f=>(fy=f,gen);gen.defined=f=>(defined=f,gen);return gen}
+ function group(values,key){const m=new Map;for(const v of values||[]){const k=key(v);if(!m.has(k))m.set(k,[]);m.get(k).push(v)}return m}
+ function max(values,accessor=x=>x){let out=-Infinity;for(const v of values||[]){const x=+accessor(v);if(isFinite(x)&&x>out)out=x}return out===-Infinity?undefined:out}
+ function pointer(ev,node=ev.currentTarget){const r=node.getBoundingClientRect(),vb=node.viewBox?.baseVal;if(vb&&r.width&&r.height)return[(ev.clientX-r.left)*vb.width/r.width+vb.x,(ev.clientY-r.top)*vb.height/r.height+vb.y];return[ev.clientX-r.left,ev.clientY-r.top]}
+ global.d3={select,scaleLinear,line,axisBottom:s=>axis(s,'bottom'),axisLeft:s=>axis(s,'left'),group,max,pointer};
+})(globalThis);
diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js
new file mode 100644
index 00000000..4ef0fa5c
--- /dev/null
+++ b/src/rtichoke/summary_report/performance_table_renderer.js
@@ -0,0 +1,175 @@
+/* R/Reactable-parity renderer for lightweight summary-report performance tables. */
+(function () {
+ const COLORS = ["#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#07004D", "#E6AB02", "#FE5F55", "#54494B", "#006E90", "#BC96E6", "#52050A", "#1F271B", "#BE7C4D", "#63768D", "#08A045", "#320A28", "#82FF9E", "#2176FF", "#D1603D", "#585123"];
+ const PAGE_SIZE = 10;
+ const fmt = v => typeof v === "number" && isFinite(v) ? v.toFixed(2) : (v ?? "");
+ const pct = v => typeof v === "number" && isFinite(v) ? `${(100 * v).toFixed(2)}%` : "";
+ const num = v => typeof v === "number" && isFinite(v) ? v : 0;
+ const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
+
+ if (!document.getElementById("rt-perf-control-style")) {
+ const style=document.createElement("style");
+ style.id="rt-perf-control-style";
+ style.textContent=`
+ .rt-check-inline{display:inline-flex!important;align-items:center;padding-left:0!important;margin-right:14px!important;gap:6px}
+ .rt-check-inline input{position:absolute!important;opacity:0;pointer-events:none;margin:0!important}
+ .rt-check-box{width:16px;height:16px;border:2px solid #333;border-radius:2px;display:grid;place-content:center;background:#fff;flex:0 0 auto}
+ .rt-check-box>span{width:10px;height:10px;transform:scale(0);transition:transform .08s linear}
+ .rt-dual-range{position:relative!important;height:50px!important;margin-top:2px!important}
+ .rt-range-track{position:absolute;left:8px;right:8px;top:29px;height:6px;background:#e1e1e1;border-radius:3px}
+ .rt-range-fill{position:absolute;top:0;height:6px;background:#337ab7;border-radius:3px}
+ .rt-range-bubble{position:absolute;top:0;transform:translateX(-50%);padding:1px 5px;min-width:34px;text-align:center;background:#337ab7;color:#fff;border-radius:3px;font-size:11px;line-height:18px;white-space:nowrap}
+ .rt-dual-range input[type=range]{-webkit-appearance:none;appearance:none;position:absolute!important;left:0!important;top:20px!important;width:100%!important;height:24px;margin:0!important;background:transparent!important;pointer-events:none!important;outline:none}
+ .rt-dual-range input[type=range]::-webkit-slider-runnable-track{height:6px;background:transparent;border:0}
+ .rt-dual-range input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border:1px solid #999;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);pointer-events:auto}
+ .rt-dual-range input[type=range]::-moz-range-track{height:6px;background:transparent;border:0}
+ .rt-dual-range input[type=range]::-moz-range-thumb{width:18px;height:18px;border:1px solid #999;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);pointer-events:auto}
+ .rt-range-readout{display:none!important}
+ `;
+ document.head.appendChild(style);
+ }
+
+ function metricBackground(value, maxValue=1, color="lightgreen") {
+ if (!isFinite(+value) || maxValue <= 0) return "";
+ const width = Math.min(Math.abs(+value) / maxValue, 1) * 100;
+ return `linear-gradient(90deg, ${color} ${width}%, transparent ${width}%)`;
+ }
+
+ function nbBackground(value, maximum) {
+ if (!isFinite(+value) || maximum <= 0) return "";
+ const width = Math.max(-1, Math.min(+value / maximum, 1));
+ const position = (0.5 + width / 2) * 100;
+ return width >= 0
+ ? `linear-gradient(90deg, transparent 50%, lightgreen 50%, lightgreen ${position}%, transparent ${position}%)`
+ : `linear-gradient(90deg, transparent ${position}%, pink ${position}%, pink 50%, transparent 50%)`;
+ }
+
+ function confusionMatrix(r) {
+ const fp=num(r.false_positives), tn=num(r.true_negatives), fn=num(r.false_negatives);
+ const tp=r.true_positives == null ? Math.max(0, num(r.predicted_positives)-fp) : num(r.true_positives);
+ const total=tp+tn+fp+fn || 1;
+ const rows=[
+ ["Predicted Positive",tp,fp,"lightgreen","pink"],
+ ["Predicted Negative",fn,tn,"pink","lightgreen"],
+ [" ",tp+fn,fp+tn,"lightgrey","lightgrey"]
+ ];
+ const value=(x)=>`${fmt(x)} (${(100*x/total).toFixed(2)}%)`;
+ return ` | Real Positive | Real Negative | |
${rows.map(q=>`| ${q[0]} | ${value(q[1])} | ${value(q[2])} | ${value(q[1]+q[2])} |
`).join("")}
`;
+ }
+
+ function render(rows, selector, isPpcr) {
+ const host=document.querySelector(selector); if(!host) return;
+ host.innerHTML="";
+ const models=[...new Set(rows.map(r=>String(r.reference_group ?? "")))];
+ const colors=Object.fromEntries(models.map((m,i)=>[m,COLORS[i%COLORS.length]]));
+ const liftMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.lift))));
+ const nbMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.net_benefit))));
+ const valueOf=r=>isPpcr?num(r.ppcr):num(r.chosen_cutoff);
+ const sorted=[...rows].sort((a,b)=>valueOf(a)-valueOf(b));
+ const values=sorted.map(valueOf).filter(Number.isFinite);
+ const minValue=values.length?Math.min(...values):0, maxValue=values.length?Math.max(...values):1;
+ const step=Math.max(0.000001, ...values.slice(1).map((v,i)=>v-values[i]).filter(v=>v>0).slice(0,1), 0.01);
+ const selected=new Set();
+ let lower=minValue, upper=maxValue, page=0;
+
+ // R crosstalk::bscols(widths = c(12, 6, 12)): group selector on a
+ // full row, slider on a half-width row, then the full-width Reactable.
+ const filters=document.createElement("div"); filters.className="rt-filters"; filters.style.display="block"; filters.style.marginBottom="15px";
+ const modelFilter=document.createElement("div"); modelFilter.className="rt-filter-models"; modelFilter.style.width="100%"; modelFilter.style.marginBottom="15px";
+ const modelLabel=document.createElement("div"); modelLabel.className="rt-filter-label"; modelLabel.textContent="Model"; modelFilter.appendChild(modelLabel);
+ models.forEach((model,i)=>{
+ const label=document.createElement("label"); label.className="rt-check-inline";
+ const input=document.createElement("input"); input.type="checkbox"; input.value=model;
+ const box=document.createElement("span"); box.className="rt-check-box";
+ const boxFill=document.createElement("span"); boxFill.style.background=colors[model]||COLORS[i%COLORS.length]; box.appendChild(boxFill);
+ const text=document.createElement("span"); text.textContent=model; label.append(input,box,text); modelFilter.appendChild(label);
+ input.addEventListener("change",()=>{input.checked?selected.add(model):selected.delete(model);boxFill.style.transform=input.checked?"scale(1)":"scale(0)";page=0;drawPage();});
+ });
+ const rangeFilter=document.createElement("div"); rangeFilter.className="rt-filter-range"; rangeFilter.style.width="50%"; rangeFilter.style.maxWidth="520px"; rangeFilter.style.minWidth="300px";
+ const rangeLabel=document.createElement("div"); rangeLabel.className="rt-filter-label"; rangeLabel.textContent=isPpcr?"Predicted Positives Condition Rate (PPCR)":"Probability Threshold";
+ const rangeReadout=document.createElement("span"); rangeReadout.className="rt-range-readout";
+ const track=document.createElement("div"); track.className="rt-dual-range";
+ const rail=document.createElement("div"); rail.className="rt-range-track";
+ const fill=document.createElement("div"); fill.className="rt-range-fill"; rail.appendChild(fill);
+ const loBubble=document.createElement("span"), hiBubble=document.createElement("span"); loBubble.className=hiBubble.className="rt-range-bubble";
+ const lo=document.createElement("input"), hi=document.createElement("input");
+ [lo,hi].forEach(input=>{input.type="range";input.min=minValue;input.max=maxValue;input.step=step;}); lo.value=minValue; hi.value=maxValue;
+ const sync=(redraw=true)=>{
+ lower=Math.min(+lo.value,+hi.value);upper=Math.max(+lo.value,+hi.value);rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`;
+ const span=maxValue-minValue||1, lp=100*(lower-minValue)/span, hp=100*(upper-minValue)/span;
+ fill.style.left=`${lp}%`;fill.style.width=`${Math.max(0,hp-lp)}%`;
+ loBubble.textContent=fmt(lower);hiBubble.textContent=fmt(upper);loBubble.style.left=`${lp}%`;hiBubble.style.left=`${hp}%`;
+ if(redraw){page=0;drawPage();}
+ };
+ lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(rail,loBubble,hiBubble,lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track);
+ if(models.length>1) filters.appendChild(modelFilter);
+ filters.appendChild(rangeFilter); host.appendChild(filters);
+ sync(false);
+
+ const wrap=document.createElement("div"); wrap.className="rt-perf-wrap";
+ const table=document.createElement("table"); table.className="rt-perf";
+ if(isPpcr) {
+ table.innerHTML=' | Model | Predicted Positives | Performance Metrics | Net Benefit |
|---|
| Sens | Spec | PPV | NPV | Lift |
';
+ } else {
+ table.innerHTML=' | Probability Threshold | Model | Performance Metrics | Predicted Positives |
|---|
| Sens | Spec | PPV | NPV | Lift | Net Benefit |
';
+ }
+ const body=document.createElement("tbody"); table.appendChild(body); wrap.appendChild(table); host.appendChild(wrap);
+ const pager=document.createElement("div"); pager.className="rt-pager"; host.appendChild(pager);
+
+ function filteredRows() {
+ return sorted.filter(r=>{
+ const model=String(r.reference_group ?? ""), value=valueOf(r);
+ return (!selected.size||selected.has(model)) && value>=lower-1e-12 && value<=upper+1e-12;
+ });
+ }
+
+ function drawPage() {
+ const filtered=filteredRows();
+ const pages=Math.max(1,Math.ceil(filtered.length/PAGE_SIZE)); if(page>=pages) page=pages-1;
+ body.innerHTML="";
+ const start=page*PAGE_SIZE, pageRows=filtered.slice(start,start+PAGE_SIZE);
+ let previousThreshold=null;
+ pageRows.forEach((r,rowIndex)=>{
+ const tr=document.createElement("tr");
+ const model=String(r.reference_group ?? "");
+ const ppcrText=`${fmt(r.predicted_positives)} (${pct(r.ppcr)})`;
+ const modelCell=`${esc(model)} | `;
+ const metrics=[["sensitivity",1],["specificity",1],["ppv",1],["npv",1],["lift",liftMax]];
+ const metricCells=metrics.map(([k,m])=>`${fmt(r[k])} | `).join("");
+ const nbCell=`${fmt(r.net_benefit)} | `;
+ const ppcrCell=`${ppcrText} | `;
+ if(isPpcr) {
+ tr.innerHTML=`› | ${modelCell}${ppcrCell}${metricCells}${nbCell}`;
+ } else {
+ const threshold=fmt(r.chosen_cutoff);
+ const repeated=rowIndex>0 && Math.abs(num(r.chosen_cutoff)-previousThreshold)<1e-12;
+ const thresholdCell=`${threshold} | `;
+ tr.innerHTML=`› | ${thresholdCell}${modelCell}${metricCells}${nbCell}${ppcrCell}`;
+ previousThreshold=num(r.chosen_cutoff);
+ }
+ const detail=document.createElement("tr"); detail.className="detail"; detail.style.display="none";
+ const td=document.createElement("td"); td.colSpan=isPpcr?9:10; td.innerHTML=confusionMatrix(r); detail.appendChild(td);
+ tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";});
+ body.appendChild(tr); body.appendChild(detail);
+ });
+ if (pages <= 1) { pager.hidden=true; return; }
+ pager.hidden=false; pager.innerHTML="";
+ const info=document.createElement("span"); info.className="rt-page-info";
+ info.textContent=filtered.length?`${start+1}–${Math.min(start+PAGE_SIZE,filtered.length)} of ${filtered.length} rows`:`0 rows`;
+ const controls=document.createElement("span"); controls.className="rt-page-controls";
+ const button=(label,target,disabled,current=false)=>{const b=document.createElement("button");b.textContent=label;b.disabled=disabled;b.className=current?"active":"";b.addEventListener("click",()=>{page=target;drawPage();});return b;};
+ controls.appendChild(button("Previous",Math.max(0,page-1),page===0));
+ for(let i=0;i{
+ if (window.R && R.tables) {
+ render(R.tables.threshold,"#table-threshold",false);
+ render(R.tables.ppcr,"#table-ppcr",true);
+ }
+ });
+})();
diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css
new file mode 100644
index 00000000..0ed924a8
--- /dev/null
+++ b/src/rtichoke/summary_report/report_style.css
@@ -0,0 +1,47 @@
+/* Authoritative R-parity stylesheet for the lightweight summary report. */
+:root { --rt-text:#333; --rt-muted:#666; --rt-border:#ddd; --rt-panel:#fff; }
+* { box-sizing:border-box; }
+html,body { background:#fff; color:var(--rt-text); font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:14px; line-height:1.42857143; }
+body { margin:0; }
+.main-container,.container,.report,main { max-width:1040px; margin-left:auto; margin-right:auto; }
+.main-container { padding:20px 15px 60px; }
+h1,h2,h3,h4 { color:var(--rt-text); font-family:inherit; font-weight:500; line-height:1.1; }
+h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3 { font-size:24px; margin:20px 0 10px; }
+#calibration,#discrimination,#utility,#utility-decision-curve,#performance-table { margin-top:20px; margin-bottom:30px; }
+#TOC,.rt-toc { margin:15px 0 28px; } #TOC ul,.rt-toc ul { margin:0; padding-left:20px; } #TOC>ul { padding-left:0; list-style:none; } #TOC a,.rt-toc a { color:#337ab7; text-decoration:none; } #TOC a:hover,.rt-toc a:hover { color:#23527c; text-decoration:underline; }
+details { margin:0 0 20px; } summary { cursor:pointer; } summary p { display:inline; } .cheat { padding-top:15px; line-height:1.75; }
+.metric-cheat-sheet { margin:0 0 20px; }.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; }.metric-cheat-sheet > summary::marker { color:#555; }.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; }.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; }.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; }.metric-cheat-sheet thead th { font-weight:400; background:#fff; }.metric-cheat-sheet td { width:100px; font-weight:600; }.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn,.good { background:lightgreen; font-weight:600; }.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn,.bad { background:pink; font-weight:600; }
+.metric-formulas { margin:28px 0 18px; font-family:"STIXGeneral-Regular","Times New Roman",serif; font-size:16px; }.metric-formulas>div { margin:23px 0; white-space:normal; }.frac { display:inline-flex; vertical-align:middle; flex-direction:column; text-align:center; line-height:1.15; margin:0 .22em; }.frac>span:first-child { border-bottom:1px solid #333; padding:0 .18em .08em; }.frac>span:last-child { padding:.08em .18em 0; }
+#prev { width:345px; max-width:100%; margin:28px 0 20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.prevalence-row { display:grid; grid-template-columns:45px 300px; border-bottom:1px solid #eee; position:relative; }.prevalence-expander { grid-row:1; width:45px; border:0; background:#fff; color:#777; font-size:18px; cursor:pointer; }.prevalence-cell { grid-row:1; padding:7px 10px; }.prevalence-cell>strong { display:block; border-bottom:1px solid #ddd; padding-bottom:7px; margin-bottom:7px; font-weight:600; }.prevalence-value { display:flex; align-items:center; }.prevalence-track { flex:1; margin-left:8px; background:#e1e1e1; height:16px; }.prevalence-track>span { display:block; background:grey; height:16px; }.prevalence-detail { grid-column:1/3; padding:12px 45px; border-top:1px solid #eee; }
+.summary-table { min-width:310px; border:1px solid #eee; }.summary-table th,.summary-table td { border-bottom:1px solid #eee; }.auc-table { width:600px; max-width:100%; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.auc-table th,.auc-table td { min-width:300px; text-align:left; }.auc-table .prevalence-track { min-width:180px; }
+.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; }.chart { width:550px; max-width:100%; margin:0 0 20px; }
+svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100%,550px); margin:0; } #discrimination svg,#utility svg,#utility-decision-curve svg { width:min(100%,500px); margin:0; }.axis text { fill:#444; font-size:12px; }.axis path,.axis line { stroke:#444; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; fill:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; }.line { fill:none; stroke-width:2; }.ref { fill:none; stroke-width:2; stroke-dasharray:3 3; }
+.slider-wrap { width:430px; max-width:calc(100% - 80px); margin:-54px auto 28px; }.slider-label { font:16px "Open Sans",verdana,arial,sans-serif; color:#444; }input[type=range] { width:100%; }
+/* Plotly animation-slider look used by R performance curves. Keep this
+ separate from the Shiny/IonRangeSlider styling used by table filters. */
+.curve-slider-wrap { font-family:"Open Sans",verdana,arial,sans-serif; }
+.curve-slider-label { margin:0 0 7px; color:#444; font-size:12px; line-height:16px; }
+.curve-slider { -webkit-appearance:none; appearance:none; display:block; width:100%; height:18px; margin:0; padding:0; background:transparent; cursor:pointer; }
+.curve-slider:focus { outline:none; }
+.curve-slider::-webkit-slider-runnable-track { width:100%; height:4px; background:#e2e2e2; border:0; border-radius:2px; }
+.curve-slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:12px; height:12px; margin-top:-4px; border:1px solid #777; border-radius:50%; background:#fff; box-shadow:none; }
+.curve-slider::-moz-range-track { width:100%; height:4px; background:#e2e2e2; border:0; border-radius:2px; }
+.curve-slider::-moz-range-thumb { width:12px; height:12px; border:1px solid #777; border-radius:50%; background:#fff; box-shadow:none; }
+.curve-slider:hover::-webkit-slider-thumb { background:#f5f5f5; }.curve-slider:hover::-moz-range-thumb { background:#f5f5f5; }
+.tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; }
+table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; }
+.perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; }
+.rt-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,1fr); gap:30px; align-items:end; margin:0 0 15px; font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; }.rt-filter-label { display:block; margin-bottom:4px; font-weight:700; }.rt-check-inline { position:relative; display:inline-block; padding-left:20px; margin-right:10px; font-weight:400; vertical-align:middle; cursor:pointer; }.rt-check-inline input { position:absolute; margin:2px 0 0 -20px; accent-color:var(--rt-check-color,#1b9e77); }.rt-filter-range { position:relative; }.rt-range-readout { float:right; margin-top:-24px; color:#555; font-size:12px; }.rt-dual-range { position:relative; height:32px; margin-top:4px; }.rt-dual-range input[type=range] { position:absolute; left:0; top:4px; width:100%; margin:0; background:transparent; pointer-events:none; }.rt-dual-range input[type=range]::-webkit-slider-thumb { pointer-events:auto; }.rt-dual-range input[type=range]::-moz-range-thumb { pointer-events:auto; }
+.rt-perf-wrap { overflow:auto; border:1px solid #e5e5e5; border-radius:3px; background:#fff; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.rt-perf { width:100%; border-collapse:separate; border-spacing:0; margin:0; font-size:14px; }.rt-perf th,.rt-perf td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; white-space:nowrap; position:relative; }.rt-perf thead th { background:#fff; font-weight:600; color:#333; }.rt-perf .metric-group { text-align:center; border-bottom:1px solid #ddd; }.rt-perf .model-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; vertical-align:1px; }.rt-perf .expand { width:28px; text-align:center; color:#777; cursor:pointer; font-size:18px; padding-left:6px; padding-right:6px; }.rt-perf .bar-cell { background-repeat:no-repeat; background-position:center; background-size:98% 88%; }.rt-perf .detail td { background:#fafafa; padding:16px; }.rt-conf { display:inline-table; border-collapse:collapse; margin:4px 0 4px 8px; vertical-align:middle; }.rt-conf th,.rt-conf td { padding:6px 10px; border:1px solid #eee; text-align:left; min-width:105px; }.rt-conf th { position:static; background:#fff; font-weight:600; }.rt-conf .outcome { font-weight:600; }.rt-pager { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:8px 0; font-size:13px; color:#555; }.rt-page-controls { display:flex; gap:4px; align-items:center; }.rt-page-controls button { border:1px solid transparent; background:#fff; color:#337ab7; padding:5px 9px; border-radius:3px; font:inherit; cursor:pointer; }.rt-page-controls button:hover:not(:disabled) { background:#eee; }.rt-page-controls button.active { background:#337ab7; color:#fff; }.rt-page-controls button:disabled { color:#aaa; cursor:default; }
+/* Match the R template's Crosstalk checkbox styling. */
+.rt-check-inline input[type="checkbox"] { -webkit-appearance:none; appearance:none; background-color:#fff; margin:0; font:inherit; color:currentColor; width:1.15em; height:1.15em; border:.075em solid currentColor; border-radius:.15em; transform:translateY(-.075em); display:grid; place-content:center; }
+.rt-check-inline input[type="checkbox"]::before { content:""; width:.65em; height:.65em; clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%); transform:scale(0); transform-origin:bottom left; transition:120ms transform ease-in-out; box-shadow:inset 1em 1em var(--rt-check-color,#1b9e77); background-color:var(--rt-check-color,#1b9e77); }
+.rt-check-inline input[type="checkbox"]:checked::before { transform:scale(1); }
+/* Match the Shiny IonRangeSlider theme embedded by the R report. */
+.rt-range-track { top:25px !important; height:8px !important; background:linear-gradient(to bottom,#dedede -50%,#fff 150%) !important; background-color:#ededed !important; border:1px solid #ccc !important; border-radius:8px !important; }
+.rt-range-fill { height:8px !important; background:#428bca !important; border-top:1px solid #428bca !important; border-bottom:1px solid #428bca !important; }
+.rt-range-bubble { padding:1px 3px !important; background:#428bca !important; color:#fff !important; }
+.rt-dual-range input[type=range] { top:16px !important; }
+.rt-dual-range input[type=range]::-webkit-slider-thumb { width:22px !important; height:22px !important; margin-top:-7px !important; border:1px solid #ababab !important; background:#dedede !important; border-radius:22px !important; box-shadow:1px 1px 3px rgba(255,255,255,.3) !important; }
+.rt-dual-range input[type=range]::-moz-range-thumb { width:22px !important; height:22px !important; border:1px solid #ababab !important; background:#dedede !important; border-radius:22px !important; box-shadow:1px 1px 3px rgba(255,255,255,.3) !important; }
+@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}.auc-table th,.auc-table td{min-width:0}.auc-table .prevalence-track{min-width:80px}.rt-filters{grid-template-columns:1fr;gap:12px}}
diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py
index 0db544f6..8c4fb3c1 100644
--- a/src/rtichoke/summary_report/summary_report.py
+++ b/src/rtichoke/summary_report/summary_report.py
@@ -1,81 +1,101 @@
-"""
-A module for Summary Report
-"""
+"""Lightweight HTML summary reports for rtichoke."""
+from __future__ import annotations
+import json
+from pathlib import Path
+from typing import Dict, Union
+import numpy as np
+from rtichoke.calibration.calibration import _create_calibration_curve_list
+from rtichoke.performance_data.performance_data import prepare_performance_data
+from rtichoke.processing.plotly_helper_functions import _create_rtichoke_curve_list_binary
+from rtichoke.summary_report.calibration_renderer import calibration_renderer_source
-from rtichoke.processing.send_post_request_to_r_rtichoke import (
- send_requests_to_rtichoke_r,
-)
-from rtichoke.processing.transforms import (
- _create_list_data_to_adjust,
-)
-import subprocess
-
-
-def create_summary_report(probs, reals, url_api="http://localhost:4242/"):
- """Creates a summary report for rtichoke model performance.
-
- Parameters
- ----------
- probs : Dict[str, np.ndarray]
- A dictionary mapping model names to predicted probabilities.
- reals : Union[np.ndarray, Dict[str, np.ndarray]]
- The true outcome labels (0 or 1).
- url_api : str, optional
- The API endpoint URL of the R rtichoke backend.
- Defaults to ``"http://localhost:4242/"``.
- """
- rtichoke_response = send_requests_to_rtichoke_r(
- dictionary_to_send={"probs": probs, "reals": reals},
- url_api=url_api,
- endpoint="create_summary_report",
- )
- print(rtichoke_response.json()[0].keys())
-
-
-def render_summary_report():
- """
- Render the rtichoke Summary Report using Quarto.
-
- Args:
- probs (list): A list of probabilities.
- reals (list): A list of real values.
- times (list): A list of absolute numbers representing timestamps.
-
- Example:
- probs = [0.1, 0.4, 0.8]
- reals = [0, 1, 1]
- times = [1, 3, 5]
- render_summary_report(probs, reals, times)
-
- This will generate a `summary_report.html` file based on the `summary_report_template.qmd`.
- """
- # Define the path to the template and output file
- template_path = "aj_estimate_summary_report.qmd"
- output_path = "summary_report.html"
-
- # Prepare the command to render the Quarto document
- command = [
- "quarto",
- "render",
- template_path,
- "--to",
- "html",
- "--output",
- output_path, # ,
- # "--execute-params",
- # f"probs={probs},reals={reals},times={times}",
- ]
-
- # Execute the command
- subprocess.run(command, check=True)
-
-
-def create_data_for_summary_report(probs, reals, times, fixed_time_horizons):
- stratified_by = ["probability_threshold", "ppcr"]
- by = 0.1
-
- list_data_to_adjust_polars = _create_list_data_to_adjust(
- probs, reals, times, stratified_by=stratified_by, by=by, times_dict={}
- )
-
- return list_data_to_adjust_polars
+_CURVES=[("roc","ROC"),("lift","Lift"),("precision recall","Precision Recall"),("gains","Gains")]
+def _spec(data,strat,curve,label):
+ d=_create_rtichoke_curve_list_binary(performance_data=data,stratified_by=strat,curve=curve,size=500)
+ return {"label":label,"title":f"{label} Curve","x_label":d["x_label"],"y_label":d["y_label"],"x_range":d["axes_ranges"]["xaxis"],"y_range":d["axes_ranges"]["yaxis"],"groups":d["reference_group_keys"],"colors":d["colors_dictionary"],"data":d["performance_data_ready_for_curve"].to_dicts(),"references":d["reference_data"].to_dicts()}
+def _specs(d,s): return [_spec(d,s,c,l) for c,l in _CURVES]
+def _calibration(probs,reals):
+ d=_create_calibration_curve_list(probs,reals,size=550); colors={k:v[0] for k,v in d["colors_dictionary"].items()}
+ return {"deciles":d["deciles_dat"].to_dicts(),"smooth":d["smooth_dat"].to_dicts(),"reference":d["reference_data"].to_dicts(),"histogram":d["histogram_for_calibration"].to_dicts(),"ranges":d["axes_ranges"],"colors":colors,"groups":[k for k in colors if k!="reference_line"]}
+def _auc(y,p):
+ y=np.asarray(y).ravel().astype(int); p=np.asarray(p).ravel().astype(float); pos=p[y==1]; neg=p[y==0]
+ return float("nan") if not len(pos) or not len(neg) else float(np.mean(pos[:,None]>neg[None,:])+.5*np.mean(pos[:,None]==neg[None,:]))
+def _summaries(probs,reals):
+ if isinstance(reals,dict) and probs.keys()==reals.keys(): return [{"Model":k,"Prevalence":float(np.mean(reals[k])),"AUC":_auc(reals[k],probs[k])} for k in probs]
+ if not isinstance(reals,dict): return [{"Model":k,"Prevalence":float(np.mean(reals)),"AUC":_auc(reals,p)} for k,p in probs.items()]
+ return []
+def _table_data(d):
+ cols=["reference_group","chosen_cutoff","ppcr","sensitivity","specificity","ppv","npv","lift","predicted_positives","net_benefit","true_posititives","true_negatives","false_positives","false_negatives"]
+ return [{k:r.get(k) for k in cols if k in r} for r in d.to_dicts()]
+def _html(payload,sums):
+ P=json.dumps(payload,separators=(",",":"),default=str).replace("","<\\/"); S=json.dumps(sums).replace("","<\\/"); CAL=calibration_renderer_source(); D3=Path(__file__).with_name("microd3.js").read_text(encoding="utf-8")
+ return f'''Summary Report
+
Performance Metrics Cheat Sheet
| Predicted Positive | Predicted Negative |
|---|
| Real Positive | TP | FN |
|---|
| Real Negative | FP | TN |
|---|
+
Calibration
+
Discrimination
Performance Metrics Curves
Performance Metrics Curves
+
'''
+def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path:
+ threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by); ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by)
+ payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}}
+ out=Path(output_file); out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8"); return out
\ No newline at end of file
diff --git a/src/rtichoke/summary_report/summary_report_plotly.py b/src/rtichoke/summary_report/summary_report_plotly.py
new file mode 100644
index 00000000..70dbabaf
--- /dev/null
+++ b/src/rtichoke/summary_report/summary_report_plotly.py
@@ -0,0 +1,287 @@
+"""Plotly-backed chart layer for the lightweight summary report.
+
+The report shell, prevalence/AUROC widgets, and performance tables remain the
+small self-contained HTML implementation. Charts are rendered by Plotly—the
+same rendering engine used by the canonical R summary report—so visual parity
+is not limited by a hand-written SVG approximation. Plotly.js is embedded
+once in the generated file; no network access or new runtime dependency is
+required.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import re
+from typing import Dict, Union
+
+import numpy as np
+from plotly.offline import get_plotlyjs
+
+from rtichoke.calibration.calibration import create_calibration_curve
+from rtichoke.performance_data.performance_data import prepare_performance_data
+from rtichoke.processing.plotly_helper_functions import _plot_rtichoke_curve_binary
+from rtichoke.summary_report.summary_report_v2 import (
+ create_summary_report as _create_lightweight_report,
+)
+
+
+_PALETTE = [
+ "#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#07004D", "#E6AB02",
+ "#FE5F55", "#54494B", "#006E90", "#BC96E6", "#52050A", "#1F271B",
+ "#BE7C4D", "#63768D", "#08A045", "#320A28", "#82FF9E", "#2176FF",
+ "#D1603D", "#585123",
+]
+
+
+def _figure_payload(fig) -> dict:
+ """Return JSON-safe Plotly data/layout without duplicating Plotly.js."""
+ payload = json.loads(fig.to_json())
+ return {"data": payload["data"], "layout": payload["layout"]}
+
+
+def _plotly_payload(
+ probs: Dict[str, np.ndarray],
+ reals: Union[np.ndarray, Dict[str, np.ndarray]],
+ by: float,
+) -> dict:
+ threshold = prepare_performance_data(
+ probs=probs,
+ reals=reals,
+ stratified_by=("probability_threshold",),
+ by=by,
+ )
+ ppcr = prepare_performance_data(
+ probs=probs,
+ reals=reals,
+ stratified_by=("ppcr",),
+ by=by,
+ )
+
+ def curves(data, stratified_by: str):
+ return [
+ {
+ "label": "ROC",
+ "figure": _figure_payload(
+ _plot_rtichoke_curve_binary(
+ data,
+ stratified_by=stratified_by,
+ curve="roc",
+ size=500,
+ )
+ ),
+ },
+ {
+ "label": "Lift",
+ "figure": _figure_payload(
+ _plot_rtichoke_curve_binary(
+ data,
+ stratified_by=stratified_by,
+ curve="lift",
+ size=500,
+ )
+ ),
+ },
+ {
+ "label": "Precision Recall",
+ "figure": _figure_payload(
+ _plot_rtichoke_curve_binary(
+ data,
+ stratified_by=stratified_by,
+ curve="precision recall",
+ size=500,
+ )
+ ),
+ },
+ {
+ "label": "Gains",
+ "figure": _figure_payload(
+ _plot_rtichoke_curve_binary(
+ data,
+ stratified_by=stratified_by,
+ curve="gains",
+ size=500,
+ )
+ ),
+ },
+ ]
+
+ return {
+ "smooth": _figure_payload(
+ create_calibration_curve(
+ probs=probs,
+ reals=reals,
+ calibration_type="smooth",
+ size=550,
+ )
+ ),
+ "discrete": _figure_payload(
+ create_calibration_curve(
+ probs=probs,
+ reals=reals,
+ calibration_type="discrete",
+ size=550,
+ )
+ ),
+ "threshold": curves(threshold, "probability_threshold"),
+ "ppcr": curves(ppcr, "ppcr"),
+ "decision": _figure_payload(
+ _plot_rtichoke_curve_binary(
+ threshold,
+ stratified_by="probability_threshold",
+ curve="decision",
+ size=500,
+ )
+ ),
+ }
+
+
+def _wire_page_parity(
+ html: str,
+ probs: Dict[str, np.ndarray],
+ reals: Union[np.ndarray, Dict[str, np.ndarray]],
+) -> str:
+ """Match the non-chart document flow of the canonical R Markdown report."""
+ formulas = """"""
+ html, count = re.subn(
+ r'.*?',
+ formulas,
+ html,
+ count=1,
+ flags=re.DOTALL,
+ )
+ if count != 1:
+ raise RuntimeError("Could not locate summary-report metric formulas")
+
+ parity_css = """
+
+"""
+ html = html.replace("", parity_css + "", 1)
+
+ if isinstance(reals, dict) and len(reals) > 1:
+ sizes = {k: int(np.asarray(reals[k]).size) for k in probs if k in reals}
+ sizes_json = json.dumps(sizes).replace("", "<\\/")
+ palette_json = json.dumps(_PALETTE)
+ prevalence_script = f"""
+
+"""
+ html = html.replace("