diff --git a/pyproject.toml b/pyproject.toml
index 6cebcf9..43e2cf5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
"xlwt>=1.3.0",
# Visualization
"matplotlib>=3.7.0",
+ "plotly>=6.0",
]
[project.optional-dependencies]
@@ -117,6 +118,8 @@ module = [
"matplotlib",
"matplotlib.*",
"numpy",
+ "plotly",
+ "plotly.*",
"numpy.*",
"pyumya",
"pyumya.*",
diff --git a/src/excelbench/results/html_dashboard.py b/src/excelbench/results/html_dashboard.py
index 12ebc94..3296b2f 100644
--- a/src/excelbench/results/html_dashboard.py
+++ b/src/excelbench/results/html_dashboard.py
@@ -172,18 +172,25 @@ def render_html_dashboard(
if memory_json and memory_json.exists():
memory = json.loads(memory_json.read_text())
- svgs: dict[str, str] = {}
+ # Load SVGs from scatter_dir
+ heatmap_svg: str | None = None
+ scatter_svgs: dict[str, str] = {}
if scatter_dir:
- for name in ("scatter_tiers", "scatter_features", "heatmap"):
- svg_path = scatter_dir / f"{name}.svg"
+ for name, prefix in [
+ ("heatmap.svg", "heatmap-"),
+ ("scatter_tiers.svg", "sctier-"),
+ ("scatter_features.svg", "scfeat-"),
+ ]:
+ svg_path = scatter_dir / name
if svg_path.exists():
- svgs[name] = _namespace_svg_ids(svg_path.read_text(), f"{name}-")
+ scatter_svgs[name] = _namespace_svg_ids(svg_path.read_text(), prefix)
+ heatmap_svg = scatter_svgs.get("heatmap.svg")
body_parts = [
_section_nav(has_memory=memory is not None),
_section_overview(fidelity, perf),
_section_matrix(fidelity),
- _section_scatter(svgs),
+ _section_scatter(fidelity, perf, heatmap_svg, scatter_svgs),
_section_comparison(fidelity, perf),
_section_features(fidelity),
_section_performance(perf),
@@ -227,11 +234,11 @@ def render_html_dashboard(
background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline;color:#80bfff}
-.container{max-width:1440px;margin:0 auto;padding:1.5rem 1.5rem}
+.container{max-width:2800px;margin:0 auto;padding:1.5rem 3rem}
/* ── Nav ── */
nav{position:sticky;top:0;z-index:100;background:linear-gradient(135deg,#0a0a0a,#191919);
- padding:.6rem 1.5rem;display:flex;align-items:center;gap:1.5rem;
+ padding:.6rem 3rem;display:flex;align-items:center;gap:1.5rem;
box-shadow:0 2px 12px rgba(0,0,0,.4);border-bottom:1px solid #2d2d2d}
nav .brand{font-weight:700;font-size:1.1rem;color:#ededed;letter-spacing:-.02em}
nav .links{display:flex;gap:1rem;flex-wrap:wrap}
@@ -381,6 +388,28 @@ def render_html_dashboard(
.wolfxl-hero .wolf-text p{margin:.2rem 0 0;font-size:.82rem;color:#fbbf24;line-height:1.4}
.wolfxl-hero .wolf-text a{color:#fb923c;font-weight:600}
+/* ── Interactive chart containers ── */
+.chart-maximize-wrap{position:relative;margin-bottom:1rem}
+.chart-maximize-btn{position:absolute;top:6px;right:6px;z-index:10;
+ background:rgba(25,25,25,.85);border:1px solid #2d2d2d;border-radius:6px;
+ color:#a0a0a0;font-size:1rem;padding:.25rem .5rem;cursor:pointer;
+ transition:all .15s;line-height:1}
+.chart-maximize-btn:hover{background:#282828;color:#ededed;border-color:#51a8ff}
+.plotly-chart{width:100%;overflow-x:auto}
+.plotly-chart .js-plotly-plot{width:100%!important}
+
+/* ── Chart maximize modal ── */
+.chart-modal-overlay{display:none;position:fixed;inset:0;z-index:9999;
+ background:rgba(0,0,0,.88);align-items:center;justify-content:center;padding:2vh 2vw}
+.chart-modal-overlay.active{display:flex}
+.chart-modal-content{width:96vw;height:92vh;background:#0a0a0a;border-radius:12px;
+ border:1px solid #2d2d2d;position:relative;overflow:hidden}
+.chart-modal-close{position:absolute;top:10px;right:14px;z-index:10;
+ background:rgba(25,25,25,.9);border:1px solid #2d2d2d;border-radius:6px;
+ color:#a0a0a0;font-size:1.2rem;padding:.3rem .7rem;cursor:pointer;line-height:1}
+.chart-modal-close:hover{background:#282828;color:#ededed;border-color:#ff6066}
+.chart-modal-body{width:100%;height:100%;overflow:auto}
+
/* ── Misc ── */
.btn{display:inline-block;padding:.3rem .8rem;border:1px solid #2d2d2d;border-radius:6px;
font-size:.78rem;cursor:pointer;background:#191919;color:#a0a0a0}
@@ -403,6 +432,45 @@ def render_html_dashboard(
.container{padding:1rem}
.filter-box input{width:100%}
}
+@media(min-width:2000px){
+ body{font-size:16px}
+ .container{padding:2rem 4rem}
+ nav{padding:.7rem 4rem;gap:2rem}
+ nav .brand{font-size:1.3rem}
+ nav .links a{font-size:.95rem}
+ h1{font-size:2rem}
+ h2{font-size:1.6rem}
+ h3{font-size:1.25rem}
+ .stat-card .val{font-size:2.8rem}
+ .stat-card .lbl{font-size:.95rem}
+ .stat-card{padding:1.6rem}
+ table{font-size:.92rem}
+ th,td{padding:.55rem .75rem}
+ .matrix th,.matrix td{padding:.45rem .65rem;min-width:64px;font-size:.88rem}
+ .matrix .feat{min-width:160px}
+ details summary{font-size:1rem;padding:.85rem 1.2rem}
+ .meta-bar{font-size:.92rem}
+ .wolfxl-hero{padding:1.5rem 2rem}
+ .wolfxl-hero .wolf-text h3{font-size:1.2rem}
+ .wolfxl-hero .wolf-text p{font-size:.95rem}
+ .filter-box input{width:360px;font-size:.95rem;padding:.5rem .9rem}
+}
+@media(min-width:3000px){
+ body{font-size:18px}
+ .container{padding:2.5rem 5rem}
+ nav{padding:.8rem 5rem}
+ nav .brand{font-size:1.5rem}
+ h1{font-size:2.4rem}
+ h2{font-size:1.9rem}
+ h3{font-size:1.45rem}
+ .stat-card .val{font-size:3.4rem}
+ .stat-card .lbl{font-size:1.05rem}
+ table{font-size:1rem}
+ th,td{padding:.6rem .85rem}
+ .matrix th,.matrix td{padding:.5rem .75rem;min-width:76px;font-size:.95rem}
+ .matrix .feat{min-width:180px}
+ details summary{font-size:1.1rem}
+}
"""
# ====================================================================
@@ -469,6 +537,55 @@ def render_html_dashboard(
btn.textContent=open?'Expand All':'Collapse All';
});
});
+/* Chart maximize modal — all content is same-origin Plotly output, no untrusted data */
+(function(){
+ var overlay=document.createElement('div');
+ overlay.className='chart-modal-overlay';
+ var content=document.createElement('div');
+ content.className='chart-modal-content';
+ var closeBtn=document.createElement('button');
+ closeBtn.className='chart-modal-close';
+ closeBtn.title='Close';
+ closeBtn.textContent='\u00d7';
+ var modalBody=document.createElement('div');
+ modalBody.className='chart-modal-body';
+ content.appendChild(closeBtn);
+ content.appendChild(modalBody);
+ overlay.appendChild(content);
+ document.body.appendChild(overlay);
+ var closeModal=function(){
+ overlay.classList.remove('active');
+ while(modalBody.firstChild)modalBody.removeChild(modalBody.firstChild);
+ };
+ closeBtn.addEventListener('click',closeModal);
+ overlay.addEventListener('click',function(e){if(e.target===overlay)closeModal();});
+ document.addEventListener('keydown',function(e){if(e.key==='Escape')closeModal();});
+ document.querySelectorAll('.chart-maximize-btn').forEach(function(btn){
+ btn.addEventListener('click',function(){
+ var wrap=btn.closest('.chart-maximize-wrap');
+ var chart=wrap?wrap.querySelector('.plotly-chart'):null;
+ if(!chart)return;
+ while(modalBody.firstChild)modalBody.removeChild(modalBody.firstChild);
+ /* Rebuild Plotly chart in modal with full interactivity */
+ var srcPlot=chart.querySelector('.js-plotly-plot');
+ if(srcPlot&&srcPlot.data&&srcPlot.layout&&window.Plotly){
+ var plotDiv=document.createElement('div');
+ plotDiv.style.width='100%';
+ plotDiv.style.height='100%';
+ modalBody.appendChild(plotDiv);
+ overlay.classList.add('active');
+ var newLayout=Object.assign({},srcPlot.layout,{autosize:true,
+ height:content.clientHeight-20,width:content.clientWidth-20});
+ window.Plotly.newPlot(plotDiv,srcPlot.data,newLayout,
+ {displayModeBar:true,displaylogo:false,responsive:true});
+ }else{
+ /* Fallback: clone static content */
+ modalBody.appendChild(chart.cloneNode(true));
+ overlay.classList.add('active');
+ }
+ });
+ });
+})();
/* Smooth scroll */
document.querySelectorAll('nav a[href^="#"]').forEach(a=>{
a.addEventListener('click',e=>{
@@ -693,17 +810,57 @@ def _green(lib: str) -> int:
return "\n".join(rows)
-def _section_scatter(svgs: dict[str, str]) -> str:
- if not svgs:
+def _section_scatter(
+ fidelity: dict[str, Any],
+ perf: dict[str, Any] | None,
+ heatmap_svg: str | None,
+ scatter_svgs: dict[str, str] | None = None,
+) -> str:
+ if not perf and not heatmap_svg and not scatter_svgs:
return ""
+
parts = ['Fidelity vs. Throughput
']
- for key, label in [
- ("scatter_tiers", "By Feature Group"),
- ("scatter_features", "Per Feature"),
- ("heatmap", "Heatmap"),
- ]:
- if key in svgs:
- parts.append(f'{label}
{svgs[key]}
')
+
+ # Interactive Plotly scatter charts (preferred when perf data exists)
+ if perf:
+ from excelbench.results.scatter_interactive import (
+ render_interactive_scatter_features_from_data,
+ render_interactive_scatter_tiers_from_data,
+ )
+
+ tiers_html = render_interactive_scatter_tiers_from_data(fidelity, perf)
+ features_html = render_interactive_scatter_features_from_data(fidelity, perf)
+
+ parts.append(
+ 'By Feature Group
'
+ f''
+ f'
'
+ f'
{tiers_html}
'
+ )
+ parts.append(
+ 'Per Feature
'
+ f''
+ f'
'
+ f'
{features_html}
'
+ )
+ else:
+ # Fallback: embed pre-rendered static SVGs when perf data is unavailable
+ if scatter_svgs:
+ tiers_svg = scatter_svgs.get("scatter_tiers.svg")
+ feats_svg = scatter_svgs.get("scatter_features.svg")
+ if tiers_svg:
+ parts.append(f'By Feature Group
'
+ f'{tiers_svg}
')
+ if feats_svg:
+ parts.append(f'Per Feature
'
+ f'{feats_svg}
')
+
+ # Static heatmap SVG (always shown if available)
+ if heatmap_svg:
+ parts.append(f'Heatmap
{heatmap_svg}
')
+
parts.append("")
return "\n".join(parts)
diff --git a/src/excelbench/results/scatter.py b/src/excelbench/results/scatter.py
index 4bf8551..c9429e5 100644
--- a/src/excelbench/results/scatter.py
+++ b/src/excelbench/results/scatter.py
@@ -114,7 +114,7 @@
(0, 50, "#1a0a0a", "0"), # dark red tint
(50, 80, "#1a1400", "1"), # dark amber tint
(80, 100, "#0c1f12", "2"), # dark green tint (score 2)
- (100, 110, "#133d1f", "3"), # brighter green tint (score 3 — visually distinct)
+ (100, 110, "#0e2916", "3"), # subtle green tint (score 3 — distinct but gridline-friendly)
]
diff --git a/src/excelbench/results/scatter_interactive.py b/src/excelbench/results/scatter_interactive.py
new file mode 100644
index 0000000..0481afb
--- /dev/null
+++ b/src/excelbench/results/scatter_interactive.py
@@ -0,0 +1,400 @@
+"""Interactive Plotly scatter-plots: fidelity pass-rate vs. throughput.
+
+Drop-in replacement for the static matplotlib scatter panels.
+Returns self-contained HTML fragments that can be embedded in the
+single-file HTML dashboard.
+
+Public API:
+ render_interactive_scatter_tiers(fidelity_json, perf_json) -> str
+ render_interactive_scatter_features(fidelity_json, perf_json) -> str
+ render_interactive_scatter_tiers_from_data(fidelity, perf) -> str
+ render_interactive_scatter_features_from_data(fidelity, perf) -> str
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+
+# ── Reuse data pipeline + constants from the static renderer ──────
+from excelbench.results.scatter import (
+ _CAP_LABELS,
+ _CAP_MARKERS,
+ _DARK_BG,
+ _DARK_CARD,
+ _DARK_GRID,
+ _DARK_TEXT,
+ _DARK_TEXT2,
+ _FALLBACK_COLOR,
+ _FEATURE_LABELS,
+ _FEATURE_PERF_MAP,
+ _LIB_COLORS,
+ _LIB_SHORT,
+ _TIER_GROUPS,
+ _WOLFXL_MARKER_SCALE,
+ _ZONE_BANDS,
+ Point,
+ _compute_capabilities,
+ _compute_pass_rates,
+ _compute_representative_throughput,
+ _compute_throughputs,
+ _feature_points,
+ _jitter,
+ _overall_points,
+ _tier_points,
+)
+
+# ── Plotly marker-symbol mapping (matplotlib → plotly names) ──────
+_PLOTLY_MARKERS: dict[str, str] = {
+ "o": "circle", # R+W
+ "s": "square", # R (read-only)
+ "D": "diamond-wide", # W (write-only)
+}
+
+# Plotly to_html config shared across all renders
+_PLOTLY_CONFIG: dict[str, Any] = dict(
+ displayModeBar=True,
+ modeBarButtonsToRemove=["lasso2d", "select2d"],
+ displaylogo=False,
+ responsive=True,
+)
+
+
+# ====================================================================
+# Subplot axis-id helper
+# ====================================================================
+
+
+def _subplot_axis_suffix(row: int, col: int, ncols: int) -> str:
+ """Return the Plotly axis suffix for a given (row, col) in a grid.
+
+ Plotly numbers axes sequentially: first subplot → '', second → '2', etc.
+ Index = (row - 1) * ncols + col.
+ """
+ idx = (row - 1) * ncols + col
+ return "" if idx == 1 else str(idx)
+
+
+# ====================================================================
+# Panel builder
+# ====================================================================
+
+
+def _build_panel(
+ fig: go.Figure,
+ row: int,
+ col: int,
+ ncols: int,
+ points: list[Point],
+) -> None:
+ """Populate one subplot with zone bands, threshold lines, and scatter points."""
+ suffix = _subplot_axis_suffix(row, col, ncols)
+ xref = f"x{suffix}" if suffix else "x"
+ yref = f"y{suffix}" if suffix else "y"
+
+ # ── Score-zone background bands ──
+ for y_lo, y_hi, colour, _ in _ZONE_BANDS:
+ fig.add_shape(
+ type="rect",
+ xref=f"{xref} domain",
+ yref=yref,
+ x0=0, x1=1, y0=y_lo, y1=y_hi,
+ fillcolor=colour,
+ opacity=0.65,
+ layer="below",
+ line_width=0,
+ )
+
+ # ── Threshold lines ──
+ for y_val, dash_style, colour, width in [
+ (50, "dash", "#444444", 0.8),
+ (80, "dash", "#444444", 0.8),
+ (100, "solid", "#62c073", 1.0),
+ ]:
+ fig.add_hline(
+ y=y_val, row=row, col=col,
+ line=dict(color=colour, width=width, dash=dash_style),
+ layer="below",
+ )
+
+ # ── Score zone annotations (left edge) ──
+ for y_pos, label, colour in [
+ (25, "Score 0", "#e4484d"),
+ (65, "Score 1", "#e79c12"),
+ (90, "Score 2", "#4ead5b"),
+ (106, "Score 3", "#62c073"),
+ ]:
+ fig.add_annotation(
+ xref=f"{xref} domain",
+ yref=yref,
+ x=0.01, y=y_pos,
+ text=label,
+ showarrow=False,
+ font=dict(size=11, color=colour),
+ opacity=0.7,
+ xanchor="left",
+ yanchor="middle",
+ )
+
+ if not points:
+ fig.add_annotation(
+ xref=f"{xref} domain",
+ yref=f"{yref} domain",
+ x=0.5, y=0.5,
+ text="No data",
+ showarrow=False,
+ font=dict(size=14, color="#a0a0a0"),
+ )
+ return
+
+ # ── Plot each library as a separate trace for individual hover ──
+ for lib, rate, tp, cap in points:
+ color = _LIB_COLORS.get(lib, _FALLBACK_COLOR)
+ mpl_marker = _CAP_MARKERS.get(cap, "o")
+ plotly_symbol = _PLOTLY_MARKERS.get(mpl_marker, "circle")
+ display_name = _LIB_SHORT.get(lib, lib)
+
+ y = rate + _jitter(lib, scale=3.0)
+ y = max(-2.0, min(107.0, y))
+
+ is_wolfxl = lib == "wolfxl"
+ sz = int(16 * _WOLFXL_MARKER_SCALE) if is_wolfxl else 16
+ edge_color = "#fb923c" if is_wolfxl else _DARK_CARD
+ edge_width = 2.5 if is_wolfxl else 1.0
+
+ fig.add_trace(
+ go.Scatter(
+ x=[tp],
+ y=[y],
+ mode="markers+text",
+ marker=dict(
+ symbol=plotly_symbol,
+ size=sz,
+ color=color,
+ line=dict(color=edge_color, width=edge_width),
+ opacity=0.95 if is_wolfxl else 0.92,
+ ),
+ text=[display_name],
+ textposition="top center" if is_wolfxl else "bottom center",
+ textfont=dict(
+ size=14 if is_wolfxl else 11,
+ color=color,
+ family="system-ui, sans-serif",
+ ),
+ hovertemplate=(
+ f"{display_name}
"
+ f"Pass Rate: {rate:.1f}%
"
+ f"Throughput: %{{x:,.0f}} ops/s
"
+ f"Capability: {_CAP_LABELS.get(cap, cap)}"
+ ""
+ ),
+ showlegend=False,
+ ),
+ row=row, col=col,
+ )
+
+
+# ====================================================================
+# Layout & figure builders
+# ====================================================================
+
+
+def _apply_layout(fig: go.Figure, title: str, nrows: int, ncols: int) -> None:
+ """Apply shared dark-theme layout settings to a multi-subplot figure."""
+ fig.update_layout(
+ title=dict(
+ text=title,
+ font=dict(size=20, color=_DARK_TEXT, family="system-ui, sans-serif"),
+ x=0.5,
+ xanchor="center",
+ ),
+ paper_bgcolor=_DARK_BG,
+ plot_bgcolor=_DARK_CARD,
+ font=dict(color=_DARK_TEXT, family="system-ui, sans-serif"),
+ margin=dict(l=70, r=40, t=90, b=90),
+ showlegend=False,
+ height=550 * nrows + 130,
+ )
+
+ # Style all axes
+ axis_style: dict[str, Any] = dict(
+ gridcolor=_DARK_GRID,
+ gridwidth=0.5,
+ zerolinecolor=_DARK_GRID,
+ linecolor=_DARK_GRID,
+ tickfont=dict(size=12, color=_DARK_TEXT2),
+ )
+
+ n_panels = nrows * ncols
+ for i in range(1, n_panels + 1):
+ suffix = "" if i == 1 else str(i)
+ fig.update_layout(**{
+ f"xaxis{suffix}": dict(
+ type="log",
+ title=dict(text="Throughput (ops/s)", font=dict(size=13, color=_DARK_TEXT)),
+ **axis_style,
+ ),
+ f"yaxis{suffix}": dict(
+ range=[-5, 110],
+ title=dict(text="Pass Rate (%)", font=dict(size=13, color=_DARK_TEXT)),
+ **axis_style,
+ ),
+ })
+
+ # Modebar customization
+ fig.update_layout(
+ modebar=dict(
+ bgcolor="rgba(0,0,0,0)",
+ color=_DARK_TEXT2,
+ activecolor="#51a8ff",
+ ),
+ )
+
+
+def _build_tiers_figure(
+ fidelity: dict[str, Any],
+ perf: dict[str, Any],
+) -> go.Figure:
+ """Build the 1x3 tier scatter grid."""
+ pass_rates = _compute_pass_rates(fidelity)
+ throughputs = _compute_throughputs(perf)
+ rep_tp = _compute_representative_throughput(perf)
+ caps = _compute_capabilities(fidelity)
+
+ tier_titles = [name for name, _ in _TIER_GROUPS] + ["Overall"]
+ fig = make_subplots(
+ rows=1, cols=3,
+ subplot_titles=tier_titles,
+ horizontal_spacing=0.06,
+ )
+
+ # Tier panels
+ for col_idx, (_, tier_features) in enumerate(_TIER_GROUPS, start=1):
+ pts = _tier_points(tier_features, pass_rates, throughputs, caps)
+ _build_panel(fig, 1, col_idx, ncols=3, points=pts)
+
+ # Overall panel
+ overall = _overall_points(pass_rates, rep_tp, caps)
+ _build_panel(fig, 1, 3, ncols=3, points=overall)
+
+ _apply_layout(fig, "ExcelBench \u2014 Fidelity vs. Throughput by Feature Group", 1, 3)
+
+ # Style subplot titles only (not zone labels added by _build_panel)
+ n_subplot_titles = len(tier_titles)
+ for ann in fig.layout.annotations[:n_subplot_titles]:
+ ann.font = dict(size=16, color=_DARK_TEXT, family="system-ui, sans-serif")
+
+ _add_footer_annotations(fig)
+ return fig
+
+
+def _build_features_figure(
+ fidelity: dict[str, Any],
+ perf: dict[str, Any],
+) -> go.Figure:
+ """Build the 2x3 per-feature scatter grid."""
+ pass_rates = _compute_pass_rates(fidelity)
+ throughputs = _compute_throughputs(perf)
+ caps = _compute_capabilities(fidelity)
+
+ features = list(_FEATURE_PERF_MAP.keys())
+ feature_titles = [_FEATURE_LABELS.get(f, f) for f in features]
+
+ fig = make_subplots(
+ rows=2, cols=3,
+ subplot_titles=feature_titles,
+ vertical_spacing=0.12,
+ horizontal_spacing=0.06,
+ )
+
+ for idx, feature in enumerate(features):
+ r = idx // 3 + 1
+ c = idx % 3 + 1
+ pts = _feature_points(feature, pass_rates, throughputs, caps)
+ _build_panel(fig, r, c, ncols=3, points=pts)
+
+ _apply_layout(fig, "ExcelBench \u2014 Per-Feature Fidelity vs. Throughput", 2, 3)
+
+ # Style subplot titles only (not zone labels added by _build_panel)
+ n_subplot_titles = len(feature_titles)
+ for ann in fig.layout.annotations[:n_subplot_titles]:
+ ann.font = dict(size=16, color=_DARK_TEXT, family="system-ui, sans-serif")
+
+ _add_footer_annotations(fig)
+ return fig
+
+
+def _add_footer_annotations(fig: go.Figure) -> None:
+ """Add capability legend and jitter footnote at the bottom of the figure."""
+ legend_parts = []
+ for cap, mpl_marker in _CAP_MARKERS.items():
+ symbol_char = {"o": "\u25cf", "s": "\u25a0", "D": "\u25c6"}.get(mpl_marker, "\u25cf")
+ legend_parts.append(f"{symbol_char} {_CAP_LABELS[cap]}")
+ legend_text = " ".join(legend_parts)
+
+ fig.add_annotation(
+ text=f"{legend_text} | Y-positions include \u00b13% jitter for visibility",
+ xref="paper", yref="paper",
+ x=0.5, y=-0.08,
+ showarrow=False,
+ font=dict(size=12, color=_DARK_TEXT2),
+ xanchor="center",
+ )
+
+
+# ====================================================================
+# Public API — returns HTML fragments
+# ====================================================================
+
+
+def render_interactive_scatter_tiers(
+ fidelity_json: Path,
+ perf_json: Path,
+) -> str:
+ """Generate an interactive 1x3 tier scatter grid as an HTML fragment.
+
+ The returned string includes the plotly.js library inline.
+ """
+ fidelity = json.loads(fidelity_json.read_text())
+ perf = json.loads(perf_json.read_text())
+ fig = _build_tiers_figure(fidelity, perf)
+ return str(fig.to_html(full_html=False, include_plotlyjs=True, config=_PLOTLY_CONFIG))
+
+
+def render_interactive_scatter_features(
+ fidelity_json: Path,
+ perf_json: Path,
+) -> str:
+ """Generate an interactive 2x3 per-feature scatter grid as an HTML fragment.
+
+ Assumes plotly.js was already included by the tiers fragment.
+ """
+ fidelity = json.loads(fidelity_json.read_text())
+ perf = json.loads(perf_json.read_text())
+ fig = _build_features_figure(fidelity, perf)
+ return str(fig.to_html(full_html=False, include_plotlyjs=False, config=_PLOTLY_CONFIG))
+
+
+# ── Convenience: build from raw dicts (for dashboard integration) ──
+
+
+def render_interactive_scatter_tiers_from_data(
+ fidelity: dict[str, Any],
+ perf: dict[str, Any],
+) -> str:
+ """Like render_interactive_scatter_tiers but accepts pre-loaded dicts."""
+ fig = _build_tiers_figure(fidelity, perf)
+ return str(fig.to_html(full_html=False, include_plotlyjs=True, config=_PLOTLY_CONFIG))
+
+
+def render_interactive_scatter_features_from_data(
+ fidelity: dict[str, Any],
+ perf: dict[str, Any],
+) -> str:
+ """Like render_interactive_scatter_features but accepts pre-loaded dicts."""
+ fig = _build_features_figure(fidelity, perf)
+ return str(fig.to_html(full_html=False, include_plotlyjs=False, config=_PLOTLY_CONFIG))
diff --git a/tests/test_scatter_interactive.py b/tests/test_scatter_interactive.py
new file mode 100644
index 0000000..fa2bcbe
--- /dev/null
+++ b/tests/test_scatter_interactive.py
@@ -0,0 +1,127 @@
+"""Smoke tests for the interactive Plotly scatter renderer."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from excelbench.results.scatter_interactive import (
+ render_interactive_scatter_features,
+ render_interactive_scatter_features_from_data,
+ render_interactive_scatter_tiers,
+ render_interactive_scatter_tiers_from_data,
+)
+
+# ── Fixture paths (same as test_results_scatter.py) ──────────────
+
+
+def _fixture_paths() -> tuple[Path, Path]:
+ fidelity = Path("results/xlsx/results.json")
+ perf = Path("results/perf/results.json")
+ assert fidelity.exists(), "expected repo fixture results/xlsx/results.json"
+ assert perf.exists(), "expected repo fixture results/perf/results.json"
+ return fidelity, perf
+
+
+# ── Tiers tests ───────────────────────────────────────────────────
+
+
+def test_tiers_returns_html_with_plotlyjs() -> None:
+ fidelity, perf = _fixture_paths()
+ html = render_interactive_scatter_tiers(fidelity, perf)
+
+ assert isinstance(html, str)
+ assert len(html) > 10_000, "expected substantial HTML output"
+ # Plotly.js must be inlined
+ assert "plotly" in html.lower()
+ assert "