diff --git a/moal/cli.py b/moal/cli.py index 1da7d9e..f147e24 100644 --- a/moal/cli.py +++ b/moal/cli.py @@ -258,6 +258,9 @@ def simulate(config: Path, output_dir: Path | None, verbose: bool) -> None: html_path = out_dir / "dashboard_animation.html" dashboard.save_html(html_path) + html_cdn_path = out_dir / "dashboard_animation_cdn.html" + dashboard.save_html(html_cdn_path, use_cdn=True) + gif_path = out_dir / "dashboard_animation.gif" dashboard.save_gif(gif_path) dashboard.close() diff --git a/moal/dashboard.py b/moal/dashboard.py index 27bd40c..b4aba92 100644 --- a/moal/dashboard.py +++ b/moal/dashboard.py @@ -487,7 +487,7 @@ def save_gif( except Exception as exc: logger.warning("Could not save dashboard GIF: %s", exc) - def save_html(self, path: str | Path) -> None: + def save_html(self, path: str | Path, *, use_cdn: bool = False) -> None: """Export the animated figure as a standalone HTML file. The exported file embeds an iteration slider and play/pause buttons so @@ -498,12 +498,17 @@ def save_html(self, path: str | Path) -> None: ---------- path : str or Path Destination file path (should end in ``.html``). + use_cdn : bool, optional + When ``True``, the Plotly JS bundle is loaded from the Plotly CDN + instead of being embedded in the file. This reduces the file size + from ~3 MB to a few KB but requires an internet connection to view. + Default is ``False`` (fully self-contained file). """ animated_fig = self._build_animated_figure() animated_fig.update_layout(width=self._export_width, height=self._export_height) animated_fig.write_html( str(path), - include_plotlyjs=True, + include_plotlyjs="cdn" if use_cdn else True, post_script=_PLAY_PAUSE_SCRIPT, auto_play=False, ) @@ -1151,10 +1156,10 @@ def _build_animated_figure(self) -> go.Figure: "args": [ [str(i + 1)], { - "frame": {"duration": 350, "redraw": False}, + "frame": {"duration": 700, "redraw": False}, "mode": "immediate", "transition": { - "duration": 220, + "duration": 300, "easing": _EASING, "ordering": "layout first", }, diff --git a/moal/preprocessing.py b/moal/preprocessing.py index da0f89f..364f2f2 100644 --- a/moal/preprocessing.py +++ b/moal/preprocessing.py @@ -58,7 +58,7 @@ def canonicalize(self, smiles: str) -> str | None: if mol is None or mol.GetNumAtoms() == 0: logger.warning("SMILES reduced to empty molecule after salt stripping: %s", smiles) return None - return Chem.MolToSmiles(mol, isomericSmiles=True) + return str(Chem.MolToSmiles(mol, isomericSmiles=True)) def process_batch(self, smiles_list: list[str]) -> tuple[list[str], list[str]]: """Canonicalize a batch of SMILES strings. diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 3f2324f..12467b0 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -554,6 +554,44 @@ def test_html_disables_plotly_builtin_autoplay(self, tmp_path): assert re.search(r"Plotly\.animate\('[^']+', null\);", content) is None assert "redraw: false" in content + def test_save_html_cdn_omits_embedded_plotlyjs(self, tmp_path): + """save_html(use_cdn=True) must load Plotly from CDN instead of embedding it.""" + db = LiveDashboard(n_iterations=2, n_compounds=20) + records = _make_records(4) + for _ in range(2): + db.update(records, activity_threshold=7.0, iter_drc_cost=5.0, iter_ps_cost=1.0) + + cdn_path = tmp_path / "dashboard_cdn.html" + embedded_path = tmp_path / "dashboard_embedded.html" + db.save_html(cdn_path, use_cdn=True) + db.save_html(embedded_path, use_cdn=False) + db.close() + + cdn_content = cdn_path.read_text() + + # CDN version must reference the Plotly CDN URL + assert "cdn.plot.ly" in cdn_content + # CDN version must be substantially smaller (no embedded JS bundle) + assert cdn_path.stat().st_size < embedded_path.stat().st_size + + def test_slider_and_playback_use_same_frame_duration(self, tmp_path): + """Slider step args and the injected play script must use the same frame/transition durations.""" + db = LiveDashboard(n_iterations=2, n_compounds=20) + records = _make_records(4) + for _ in range(2): + db.update(records, activity_threshold=7.0, iter_drc_cost=5.0, iter_ps_cost=1.0) + + html_path = tmp_path / "dashboard.html" + db.save_html(html_path) + db.close() + + content = html_path.read_text() + # Slider step JSON uses compact form; injected play script uses spaced form + assert '"duration":700' in content # slider step frame duration + assert "duration: 700" in content # injected JS play animation frame duration + assert '"duration":300' in content # slider step transition duration + assert "duration: 300" in content # injected JS play animation transition duration + def test_animated_frames_include_per_iteration_axis_layout(self): """Animated HTML frames must carry per-iteration axis updates, not just trace data.""" db = LiveDashboard(n_iterations=3, n_compounds=20)