Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
154 commits
Select commit Hold shift + click to select a range
fa2ac4b
Prototype native D3 summary report
uriahf Aug 19, 2026
27b0231
Add summary report preview example
uriahf Aug 19, 2026
caf3fa8
Publish summary report in docs previews
uriahf Aug 19, 2026
0992755
Test native summary report output
uriahf Aug 19, 2026
cf6fc30
Verify summary report prepares performance data once
uriahf Aug 19, 2026
191698d
Add summary report preparation benchmark
uriahf Aug 19, 2026
51c6562
Keep benchmark directory
uriahf Aug 19, 2026
f5c584a
Document summary report benchmark
uriahf Aug 19, 2026
de7355b
Link summary report proof of concept from docs
uriahf Aug 19, 2026
75638d4
Mark summary report proof of concept
uriahf Aug 19, 2026
45ee6e4
Document summary report preview
uriahf Aug 19, 2026
bfef12c
Keep summary report preview marker
uriahf Aug 19, 2026
f9b5d28
Clarify benchmark scope
uriahf Aug 19, 2026
fa4f12e
Mark summary report preview example
uriahf Aug 19, 2026
9d762c7
Add preview note
uriahf Aug 19, 2026
11a9a9e
Mark summary report benchmark
uriahf Aug 19, 2026
b093bb1
Mark summary report tests
uriahf Aug 19, 2026
e5095e0
Describe D3 summary report proof of concept
uriahf Aug 19, 2026
66741b6
Document preview output name
uriahf Aug 19, 2026
35aaa91
Note summary report demo purpose
uriahf Aug 19, 2026
c67cd86
Note benchmark purpose
uriahf Aug 19, 2026
9de3007
Note summary report tests
uriahf Aug 19, 2026
cf9e5e6
Clean up POC marker files
uriahf Aug 19, 2026
7849047
Clean up POC marker files
uriahf Aug 19, 2026
b586159
Clean up POC marker files
uriahf Aug 19, 2026
53a0407
Clean up POC marker files
uriahf Aug 19, 2026
992dcb2
Clean up POC marker files
uriahf Aug 19, 2026
8385263
Clean up POC marker files
uriahf Aug 19, 2026
10c773d
Clean up POC marker files
uriahf Aug 19, 2026
010ef89
Clean up POC marker files
uriahf Aug 19, 2026
b65fa0e
Clean up POC marker files
uriahf Aug 19, 2026
ecf8b27
Clean up POC marker files
uriahf Aug 19, 2026
e48fcd4
Clean up POC marker files
uriahf Aug 19, 2026
1abd5cc
Clean up POC marker files
uriahf Aug 19, 2026
42bea67
Clean up POC marker files
uriahf Aug 19, 2026
b206713
Clean up POC marker files
uriahf Aug 19, 2026
4ad0886
Match D3 summary charts to rtichoke Plotly styling
uriahf Aug 19, 2026
f172f70
Fix D3 report JavaScript rendering
uriahf Aug 19, 2026
c84d3cb
Strengthen summary report HTML smoke test
uriahf Aug 19, 2026
f454223
Match R summary report with tabbed curves
uriahf Aug 19, 2026
dd2975a
Add standalone Reactable embedding spike
uriahf Aug 19, 2026
ac58102
Update summary report test for tabbed renderer
uriahf Aug 19, 2026
3e0b837
Add R report hierarchy and PPCR support
uriahf Aug 19, 2026
551dbf8
Test shared threshold and PPCR preparation
uriahf Aug 19, 2026
9e857ee
Test R-parity summary report sections
uriahf Aug 19, 2026
adcdac6
Render performance tables with lightweight standalone JS
uriahf Aug 19, 2026
9471031
Test standalone performance table rendering
uriahf Aug 19, 2026
35c059a
Polish summary report visual parity
uriahf Aug 19, 2026
63ae16a
Render R summary report reference in PR preview
uriahf Aug 19, 2026
92886ee
Fix R reference report dependency install
uriahf Aug 19, 2026
44ad8ec
Match lightweight report styling to rendered R reference
uriahf Aug 19, 2026
b4316e0
Rebuild summary report around R report structure
uriahf Aug 19, 2026
9f0f29f
Fix summary report native HTML test
uriahf Aug 19, 2026
11656bd
Match calibration layout to R report
uriahf Aug 19, 2026
78d9aa4
Extract calibration renderer for focused parity work
uriahf Aug 19, 2026
f2f0305
Add calibration renderer asset loader
uriahf Aug 19, 2026
5990c19
Test extracted calibration renderer asset
uriahf Aug 19, 2026
fd07698
Document calibration renderer extraction
uriahf Aug 19, 2026
59eb020
Match calibration hover colors to Plotly
uriahf Aug 19, 2026
d0245fa
Fix calibration histogram clip path
uriahf Aug 19, 2026
5bd4066
Verify calibration renderer can be embedded
uriahf Aug 19, 2026
a41406f
Wire extracted calibration renderer into report
uriahf Aug 19, 2026
4aa74bc
Merge pull request #320 from uriahf/agent/calibration-report-parity
uriahf Aug 19, 2026
37de426
Fix calibration renderer asset assertion
uriahf Aug 19, 2026
3365551
Add fast summary report preview workflow
uriahf Aug 19, 2026
762cf34
Refine calibration hover styling to match Plotly
uriahf Aug 19, 2026
c91b01a
Match calibration markers to Plotly scatter styling
uriahf Aug 19, 2026
edff628
Match calibration subplot geometry to Plotly
uriahf Aug 19, 2026
5c67a7a
Match calibration legend to R report
uriahf Aug 19, 2026
eafdb74
Match lightweight performance tables to R report
uriahf Aug 19, 2026
af45f56
Load performance table parity renderer in summary report
uriahf Aug 19, 2026
40b5636
Add Plotly-parity performance curve renderer
uriahf Aug 19, 2026
87b7b21
Expose performance curve renderer asset
uriahf Aug 19, 2026
960350d
Wire Plotly-parity curve renderer into summary report
uriahf Aug 19, 2026
da047dc
Use modular summary report renderer
uriahf Aug 19, 2026
eed76c4
Update calibration renderer geometry assertions
uriahf Aug 19, 2026
b4cf938
Match performance curves to R Plotly markers
uriahf Aug 19, 2026
59bcea6
Refine decision curve reference parity
uriahf Aug 19, 2026
266f313
Add R-style summary report layout polish
uriahf Aug 19, 2026
e5e6783
Apply R-style summary report layout polish
uriahf Aug 19, 2026
7943499
Match calibration size used by R summary report
uriahf Aug 19, 2026
67df822
Match curve size used by R summary report
uriahf Aug 19, 2026
45100a3
Align calibration test with R report geometry
uriahf Aug 19, 2026
9a1bc4f
Match summary report structure to R reference
uriahf Aug 19, 2026
145cefd
Publish summary report as direct PR preview
uriahf Aug 19, 2026
2410cf4
Add R-style report navigation and cheat sheet
uriahf Aug 19, 2026
1b48ade
Style R report TOC and metric cheat sheet
uriahf Aug 19, 2026
d4aad3b
Fix summary report chrome compatibility
uriahf Aug 19, 2026
157527d
Tighten calibration Plotly parity
uriahf Aug 19, 2026
b6f63fc
Publish report preview outside docs preview tree
uriahf Aug 20, 2026
22c92c7
Verify summary report in documentation preview
uriahf Aug 20, 2026
becb55d
Match performance curves to R Plotly styling
uriahf Aug 20, 2026
fea726f
Match performance table to R reactable styling
uriahf Aug 20, 2026
96966ee
Remove duplicate report chrome
uriahf Aug 20, 2026
57f8e90
Remove redundant standalone report preview workflow
uriahf Aug 20, 2026
a29393b
Bundle minimal D3-compatible runtime for summary reports
uriahf Aug 20, 2026
886bdf3
Fix minimal runtime scale setters
uriahf Aug 20, 2026
957b9c2
Make summary report self-contained
uriahf Aug 20, 2026
428d166
Test summary report is self-contained
uriahf Aug 20, 2026
9423c5e
Fix summary report test formatting
uriahf Aug 20, 2026
60a8200
Apply Ruff formatting to summary report test
uriahf Aug 20, 2026
bb8aabc
Fix self-contained summary report runtime
uriahf Aug 20, 2026
07eb510
Inline summary report D3 runtime
uriahf Aug 20, 2026
4b13448
Fix self-contained report integration
uriahf Aug 20, 2026
e52c34f
Match R report cheat sheet and prevalence presentation
uriahf Aug 20, 2026
cbcc09c
Style R reference metric formulas and prevalence widget
uriahf Aug 20, 2026
30dc3f6
Populate prevalence details from report inputs
uriahf Aug 20, 2026
96dc733
Fix prevalence parity widget initialization
uriahf Aug 20, 2026
ebc9eda
Match R report plot title sizing
uriahf Aug 20, 2026
ffe0666
Consolidate summary report styling layer
uriahf Aug 20, 2026
c0c2b35
Complete authoritative report stylesheet
uriahf Aug 20, 2026
a092a78
Match R prevalence rows for shared outcomes
uriahf Aug 20, 2026
c33a2e7
Match R report heading weight
uriahf Aug 20, 2026
57136a4
Match R performance table pagination
uriahf Aug 20, 2026
cf8dc46
Fix PPCR table detail span
uriahf Aug 20, 2026
d2e11d1
Restore TP in summary table details
uriahf Aug 20, 2026
0da9d11
Match R AUROC summary widget
uriahf Aug 20, 2026
4de6206
Test R-style AUROC summary widget
uriahf Aug 20, 2026
9e669b5
Size AUROC widget like R Reactable
uriahf Aug 20, 2026
20de49e
Keep performance table styles consolidated
uriahf Aug 20, 2026
be3f842
Match R performance table filters
uriahf Aug 20, 2026
0386d13
Style performance table filters like R
uriahf Aug 20, 2026
dac46b0
Test R-style performance filters
uriahf Aug 20, 2026
2c6a6c1
Match R full-width curve layout
uriahf Aug 20, 2026
cae8d3e
Let report curves fill the R report column
uriahf Aug 20, 2026
ecb52ba
Match R curve widget geometry
uriahf Aug 20, 2026
8eafa1c
Pass curve strata to D3 renderer
uriahf Aug 20, 2026
b460395
Restore R-style curve strata sliders
uriahf Aug 20, 2026
01e4ffb
Cover R-style curve geometry and slider
uriahf Aug 20, 2026
d0b51a9
Fix curve parity regression assertions
uriahf Aug 20, 2026
d4ecd1f
Actually wire parity renderer into discrimination tabs
uriahf Aug 20, 2026
67e2e27
Match R Plotly curve widget and slider geometry
uriahf Aug 20, 2026
57c1f99
Keep curve slider inside the R-sized widget
uriahf Aug 20, 2026
ebd2159
Test in-widget R-style curve slider
uriahf Aug 20, 2026
9b72469
Match R Crosstalk filter row layout
uriahf Aug 20, 2026
2676152
Match R report TOC hierarchy
uriahf Aug 20, 2026
c0c2407
Share exact preview data with R reference
uriahf Aug 20, 2026
4bc5ed7
Render Python and R preview from identical data
uriahf Aug 20, 2026
f78b369
Match R Reactable performance column order
uriahf Aug 20, 2026
7098234
Match R performance controls more closely
uriahf Aug 20, 2026
fa3b861
Test R-style performance control parity
uriahf Aug 20, 2026
2b4726b
Fix initial performance slider render
uriahf Aug 20, 2026
17d7c8b
Match R Shiny range-slider styling
uriahf Aug 20, 2026
86f89d3
Left-align R-sized report plots
uriahf Aug 20, 2026
5fa732b
Match R Plotly curve line rendering
uriahf Aug 20, 2026
99cbf8a
Style curve slider like Plotly animation control
uriahf Aug 20, 2026
fe3ad5f
Lock R-style line-only curve rendering
uriahf Aug 20, 2026
02216cd
Test public summary report renderer
uriahf Aug 20, 2026
2bb0ee1
Use native Plotly for summary report charts
uriahf Aug 20, 2026
148a123
Route summary report through Plotly chart layer
uriahf Aug 20, 2026
04fa857
Test Plotly-backed public summary report
uriahf Aug 20, 2026
120c492
Use explicit stratification for Plotly report curves
uriahf Aug 20, 2026
11e0456
Match R report cheat sheet and prevalence layout
uriahf Aug 20, 2026
8543878
Keep Plotly report tests lightweight
uriahf Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 60 additions & 14 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,29 @@ 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
grep -q '.Reactable {' performance-table-reactable.html
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:
Expand All @@ -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: |
Expand All @@ -94,11 +85,66 @@ 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:
source-dir: great-docs/_site
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"
5 changes: 5 additions & 0 deletions SUMMARY_REPORT_POC.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions benchmarks/benchmark_summary_report.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 26 additions & 0 deletions examples/summary_report_demo.py
Original file line number Diff line number Diff line change
@@ -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")
56 changes: 56 additions & 0 deletions scripts/reactable_embed_spike.py
Original file line number Diff line number Diff line change
@@ -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"""<!doctype html>
<html><head><meta charset='utf-8'><title>Reactable standalone spike</title>
<script src='https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js'></script>
<script data-jupyter-widgets-cdn='https://cdn.jsdelivr.net/npm/' src='https://cdn.jsdelivr.net/npm/@jupyter-widgets/html-manager@*/dist/embed-amd.js'></script>
<script type='application/vnd.jupyter.widget-state+json'>{json.dumps(data['manager_state'])}</script>
</head><body>
<h1>rtichoke Reactable standalone spike</h1>
<p>No Quarto or running Jupyter kernel is used to view this page.</p>
<script type='application/vnd.jupyter.widget-view+json'>{json.dumps(data['view_specs'][0])}</script>
</body></html>"""
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()
2 changes: 1 addition & 1 deletion src/rtichoke/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
7 changes: 7 additions & 0 deletions src/rtichoke/summary_report/calibration_renderer.README.md
Original file line number Diff line number Diff line change
@@ -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.
107 changes: 107 additions & 0 deletions src/rtichoke/summary_report/calibration_renderer.js
Original file line number Diff line number Diff line change
@@ -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<dist){dist=dd;best=d}});
if(best&&dist<225)showTip(ev,best.text,hoverColor(best));else hideTip();
}).on("mouseleave",hideTip);
}
Loading
Loading