diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index e3d9e4e3a..887879dd3 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -157,6 +157,13 @@ def _correlation_grid_color(cls) -> str: return 'rgba(110, 145, 190, 0.35)' return 'rgba(120, 140, 160, 0.28)' + @classmethod + def _legend_background_color(cls) -> str: + """Return a half-transparent legend background color.""" + if cls._is_dark_mode(): + return 'rgba(0, 0, 0, 0.5)' + return 'rgba(255, 255, 255, 0.5)' + def plot_correlation_heatmap( self, corr_df: object, @@ -550,6 +557,7 @@ def _get_config() -> dict: A dict with display and mode bar settings. """ return { + 'displayModeBar': True, 'displaylogo': False, 'modeBarButtonsToRemove': [ 'select2d', @@ -560,6 +568,216 @@ def _get_config() -> dict: ], } + @staticmethod + def _modebar_legend_toggle_post_script() -> str: + """ + Return client-side code for a legend-toggle modebar button. + """ + return r""" +const graphDiv = document.getElementById('{plot_id}'); +if (!graphDiv) { + return; +} + +const parseColor = function (colorValue) { + if (!colorValue) { + return null; + } + + const rgbMatch = colorValue.match(/^rgba?\(([^)]+)\)$/); + if (rgbMatch) { + const channels = rgbMatch[1].split(',').slice(0, 3).map((value) => Number(value.trim())); + if (channels.every((value) => Number.isFinite(value))) { + return {red: channels[0], green: channels[1], blue: channels[2]}; + } + } + + const hexMatch = colorValue.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!hexMatch) { + return null; + } + + const normalizedHex = hexMatch[1].length === 3 + ? hexMatch[1].split('').map((value) => value + value).join('') + : hexMatch[1]; + return { + red: Number.parseInt(normalizedHex.slice(0, 2), 16), + green: Number.parseInt(normalizedHex.slice(2, 4), 16), + blue: Number.parseInt(normalizedHex.slice(4, 6), 16), + }; +}; + +const resolveLegendButtonFill = function (opacity) { + const referencePath = graphDiv.querySelector('.modebar-btn path'); + const referenceFill = referencePath ? window.getComputedStyle(referencePath).fill : null; + const fontColor = graphDiv._fullLayout && graphDiv._fullLayout.font + ? graphDiv._fullLayout.font.color + : null; + const parsedColor = ( + parseColor(referenceFill) + || parseColor(fontColor) + || {red: 68, green: 68, blue: 68} + ); + return ( + 'rgba(' + + parsedColor.red + + ', ' + + parsedColor.green + + ', ' + + parsedColor.blue + + ', ' + + opacity + + ')' + ); +}; + +const updateLegendButtonAppearance = function (legendVisible) { + const legendButton = graphDiv.querySelector('[data-legend-toggle="true"]'); + if (!legendButton) { + return; + } + + const legendIconPath = legendButton.querySelector('path'); + if (!legendIconPath) { + return; + } + + legendButton.classList.toggle('active', legendVisible); + legendButton.setAttribute('aria-pressed', String(legendVisible)); + legendIconPath.setAttribute( + 'style', + 'fill: ' + resolveLegendButtonFill(legendVisible ? 0.7 : 0.3) + ';', + ); +}; + +const applyLegendVisibility = function (legendVisible) { + const legend = graphDiv.querySelector('.legend'); + if (legend) { + legend.style.display = legendVisible ? 'inline' : 'none'; + legend.style.visibility = legendVisible ? 'visible' : 'hidden'; + legend.style.pointerEvents = legendVisible ? '' : 'none'; + } + + if (graphDiv.layout) { + graphDiv.layout.showlegend = legendVisible; + } + + if (graphDiv._fullLayout) { + graphDiv._fullLayout.showlegend = legendVisible; + } +}; + +const readLegendVisibility = function () { + if (graphDiv.dataset.legendVisible === 'true') { + return true; + } + + if (graphDiv.dataset.legendVisible === 'false') { + return false; + } + + const legend = graphDiv.querySelector('.legend'); + if (legend) { + return ( + window.getComputedStyle(legend).display !== 'none' + && window.getComputedStyle(legend).visibility !== 'hidden' + ); + } + + if (graphDiv.layout && typeof graphDiv.layout.showlegend === 'boolean') { + return graphDiv.layout.showlegend; + } + + if (graphDiv._fullLayout && typeof graphDiv._fullLayout.showlegend === 'boolean') { + return graphDiv._fullLayout.showlegend; + } + + return true; +}; + +const syncLegendVisibility = function (legendVisible) { + const resolvedLegendVisible = typeof legendVisible === 'boolean' + ? legendVisible + : readLegendVisibility(); + graphDiv.dataset.legendVisible = String(resolvedLegendVisible); + applyLegendVisibility(resolvedLegendVisible); + updateLegendButtonAppearance(resolvedLegendVisible); + return resolvedLegendVisible; +}; + +const toggleLegend = function (event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + const currentValue = readLegendVisibility(); + const nextValue = !currentValue; + syncLegendVisibility(nextValue); +}; + +const installLegendToggleButton = function () { + const modebar = graphDiv.querySelector('.modebar'); + if (!modebar) { + return; + } + + if (!modebar.querySelector('.modebar-group')) { + return; + } + + let legendButton = modebar.querySelector('[data-legend-toggle="true"]'); + if (!legendButton) { + const legendButtonGroup = document.createElement('div'); + legendButtonGroup.className = 'modebar-group'; + + legendButton = document.createElement('a'); + legendButton.className = 'modebar-btn'; + legendButton.href = 'javascript:void(0)'; + legendButton.setAttribute('data-title', 'Toggle legend'); + legendButton.setAttribute('data-legend-toggle', 'true'); + legendButton.setAttribute('aria-label', 'Toggle legend'); + legendButton.setAttribute('role', 'button'); + legendButton.setAttribute('tabindex', '0'); + legendButton.innerHTML = [ + '', + ].join(''); + + legendButtonGroup.appendChild(legendButton); + modebar.appendChild(legendButtonGroup); + } + + legendButton.onclick = toggleLegend; + legendButton.onkeydown = function (event) { + if (event.key === 'Enter' || event.key === ' ') { + toggleLegend(event); + } + }; + + syncLegendVisibility(); +}; + +if (graphDiv.on) { + graphDiv.on('plotly_afterplot', installLegendToggleButton); + graphDiv.on('plotly_relayout', function (eventData) { + if (eventData && typeof eventData.showlegend === 'boolean') { + syncLegendVisibility(eventData.showlegend); + return; + } + + syncLegendVisibility(); + }); +} +syncLegendVisibility(); +window.requestAnimationFrame(installLegendToggleButton); +""" + @staticmethod def _get_figure( data: object, @@ -587,6 +805,36 @@ def _get_figure( fig.update_yaxes(tickformat=',.6~g', separatethousands=True) return fig + @staticmethod + def _has_visible_legend(fig: object) -> bool: + """Return whether a figure exposes at least one legend entry.""" + + def _trace_value(trace: object, field_name: str) -> object: + value = getattr(trace, field_name, None) + if value is not None: + return value + + trace_kwargs = getattr(trace, 'kwargs', None) + if isinstance(trace_kwargs, dict): + return trace_kwargs.get(field_name) + + return None + + layout = getattr(fig, 'layout', None) + layout_showlegend = getattr(layout, 'showlegend', None) + if layout_showlegend is False: + return False + + for trace in getattr(fig, 'data', ()): + if _trace_value(trace, 'visible') is False: + continue + if _trace_value(trace, 'showlegend') is False: + continue + if _trace_value(trace, 'name'): + return True + + return False + def _show_figure( self, fig: object, @@ -607,16 +855,21 @@ def _show_figure( if in_pycharm() or display is None or HTML is None: fig.show(config=config) else: + post_script = None + if self._has_visible_legend(fig): + post_script = self._modebar_legend_toggle_post_script() html_fig = pio.to_html( fig, include_plotlyjs='cdn', full_html=False, config=config, + post_script=post_script, ) display(HTML(html_fig)) - @staticmethod + @classmethod def _get_layout( + cls, title: str, axes_labels: object, shapes: list | None = None, @@ -649,6 +902,7 @@ def _get_layout( 'text': title, }, legend={ + 'bgcolor': cls._legend_background_color(), 'xanchor': 'right', 'x': 1.0, 'yanchor': 'top', @@ -1041,6 +1295,7 @@ def plot_powder_meas_vs_calc( }, title={'text': plot_spec.title}, legend={ + 'bgcolor': self._legend_background_color(), 'xanchor': 'right', 'x': 1.0, 'yanchor': 'top', diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 20cff4903..3905ee278 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -49,6 +49,22 @@ def test_correlation_colorscale_uses_white_center_in_light_mode(monkeypatch): assert pp.PlotlyPlotter._correlation_colorscale()[1] == (0.5, '#f7f7f7') +def test_legend_background_color_uses_light_overlay_in_light_mode(monkeypatch): + import easydiffraction.display.plotters.plotly as pp + + monkeypatch.setattr(pp.PlotlyPlotter, '_is_dark_mode', staticmethod(lambda: False)) + + assert pp.PlotlyPlotter._legend_background_color() == 'rgba(255, 255, 255, 0.5)' + + +def test_legend_background_color_uses_dark_overlay_in_dark_mode(monkeypatch): + import easydiffraction.display.plotters.plotly as pp + + monkeypatch.setattr(pp.PlotlyPlotter, '_is_dark_mode', staticmethod(lambda: True)) + + assert pp.PlotlyPlotter._legend_background_color() == 'rgba(0, 0, 0, 0.5)' + + def test_get_trace_and_plot(monkeypatch): import easydiffraction.display.plotters.plotly as pp @@ -87,7 +103,7 @@ def __init__(self, **kwargs): class DummyPIO: @staticmethod - def to_html(fig, include_plotlyjs=None, full_html=None, config=None): + def to_html(fig, include_plotlyjs=None, full_html=None, config=None, post_script=None): return '
plot
' dummy_display_calls = {'count': 0} @@ -129,6 +145,139 @@ def __init__(self, html): assert dummy_display_calls['count'] == 1 or shown['count'] == 1 +def test_show_figure_adds_legend_toggle_script_to_html_output(monkeypatch): + import easydiffraction.display.plotters.plotly as pp + + monkeypatch.setattr(pp, 'in_pycharm', lambda: False) + + captured = {} + + class DummyFig: + def update_xaxes(self, **kwargs): + pass + + def update_yaxes(self, **kwargs): + pass + + def show(self, **kwargs): + captured['show_called'] = True + + class DummyScatter: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class DummyGO: + class Scatter(DummyScatter): + pass + + class Figure(DummyFig): + def __init__(self, data=None, layout=None): + self.data = data + self.layout = layout + + class Layout: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class DummyPIO: + @staticmethod + def to_html(fig, include_plotlyjs=None, full_html=None, config=None, post_script=None): + captured['config'] = config + captured['post_script'] = post_script + return '
plot
' + + def dummy_display(obj): + captured['displayed_html'] = obj.html + + class DummyHTML: + def __init__(self, html): + self.html = html + + monkeypatch.setattr(pp, 'go', DummyGO) + monkeypatch.setattr(pp, 'pio', DummyPIO) + monkeypatch.setattr(pp, 'display', dummy_display) + monkeypatch.setattr(pp, 'HTML', DummyHTML) + + plotter = pp.PlotlyPlotter() + plotter.plot_powder( + [0, 1, 2], + y_series=[[1, 2, 3]], + labels=['calc'], + axes_labels=['x', 'y'], + title='t', + height=None, + ) + + assert captured.get('show_called') is not True + assert captured['config']['displayModeBar'] is True + assert captured['config']['displaylogo'] is False + assert 'data-legend-toggle="true"' in captured['post_script'] + assert 'Toggle legend' in captured['post_script'] + assert 'graphDiv.dataset.legendVisible' in captured['post_script'] + assert 'const applyLegendVisibility = function (legendVisible) {' in captured['post_script'] + assert "legend.style.display = legendVisible ? 'inline' : 'none';" in captured['post_script'] + assert 'const readLegendVisibility = function () {' in captured['post_script'] + assert ( + "if (graphDiv.layout && typeof graphDiv.layout.showlegend === 'boolean')" + in captured['post_script'] + ) + assert "legendButton.classList.toggle('active', legendVisible);" in captured['post_script'] + assert "graphDiv.on('plotly_relayout', function (eventData) {" in captured['post_script'] + assert 'legendButton.onclick = toggleLegend;' in captured['post_script'] + assert 'resolveLegendButtonFill(legendVisible ? 0.7 : 0.3)' in captured['post_script'] + assert "legendButtonGroup.className = 'modebar-group';" in captured['post_script'] + assert 'modebar.appendChild(legendButtonGroup);' in captured['post_script'] + assert 'legendButton.innerHTML' in captured['post_script'] + assert 'height="1em" width="1em"' in captured['post_script'] + assert captured['displayed_html'] == '
plot
' + + +def test_show_figure_skips_legend_toggle_script_without_legend(monkeypatch): + import easydiffraction.display.plotters.plotly as pp + + monkeypatch.setattr(pp, 'in_pycharm', lambda: False) + + captured = {} + + class DummyTrace: + def __init__(self, name=None, showlegend=None, visible=None): + self.name = name + self.showlegend = showlegend + self.visible = visible + + class DummyFig: + def __init__(self): + self.data = [DummyTrace(name=None, showlegend=False)] + self.layout = type('DummyLayout', (), {'showlegend': None})() + + def show(self, **kwargs): + captured['show_called'] = True + + class DummyPIO: + @staticmethod + def to_html(fig, include_plotlyjs=None, full_html=None, config=None, post_script=None): + captured['post_script'] = post_script + return '
plot
' + + def dummy_display(obj): + captured['displayed_html'] = obj.html + + class DummyHTML: + def __init__(self, html): + self.html = html + + monkeypatch.setattr(pp, 'pio', DummyPIO) + monkeypatch.setattr(pp, 'display', dummy_display) + monkeypatch.setattr(pp, 'HTML', DummyHTML) + + plotter = pp.PlotlyPlotter() + plotter._show_figure(DummyFig()) + + assert captured.get('show_called') is not True + assert captured['post_script'] is None + assert captured['displayed_html'] == '
plot
' + + def test_plotly_single_crystal_trace_and_plot(monkeypatch): import easydiffraction.display.plotters.plotly as pp @@ -166,7 +315,7 @@ def __init__(self, **kwargs): class DummyPIO: @staticmethod - def to_html(fig, include_plotlyjs=None, full_html=None, config=None): + def to_html(fig, include_plotlyjs=None, full_html=None, config=None, post_script=None): return '
plot
' dummy_display_calls = {'count': 0}