@@ -157,6 +157,13 @@ def _correlation_grid_color(cls) -> str:
157157 return 'rgba(110, 145, 190, 0.35)'
158158 return 'rgba(120, 140, 160, 0.28)'
159159
160+ @classmethod
161+ def _legend_background_color (cls ) -> str :
162+ """Return a half-transparent legend background color."""
163+ if cls ._is_dark_mode ():
164+ return 'rgba(0, 0, 0, 0.5)'
165+ return 'rgba(255, 255, 255, 0.5)'
166+
160167 def plot_correlation_heatmap (
161168 self ,
162169 corr_df : object ,
@@ -550,6 +557,7 @@ def _get_config() -> dict:
550557 A dict with display and mode bar settings.
551558 """
552559 return {
560+ 'displayModeBar' : True ,
553561 'displaylogo' : False ,
554562 'modeBarButtonsToRemove' : [
555563 'select2d' ,
@@ -560,6 +568,216 @@ def _get_config() -> dict:
560568 ],
561569 }
562570
571+ @staticmethod
572+ def _modebar_legend_toggle_post_script () -> str :
573+ """
574+ Return client-side code for a legend-toggle modebar button.
575+ """
576+ return r"""
577+ const graphDiv = document.getElementById('{plot_id}');
578+ if (!graphDiv) {
579+ return;
580+ }
581+
582+ const parseColor = function (colorValue) {
583+ if (!colorValue) {
584+ return null;
585+ }
586+
587+ const rgbMatch = colorValue.match(/^rgba?\(([^)]+)\)$/);
588+ if (rgbMatch) {
589+ const channels = rgbMatch[1].split(',').slice(0, 3).map((value) => Number(value.trim()));
590+ if (channels.every((value) => Number.isFinite(value))) {
591+ return {red: channels[0], green: channels[1], blue: channels[2]};
592+ }
593+ }
594+
595+ const hexMatch = colorValue.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
596+ if (!hexMatch) {
597+ return null;
598+ }
599+
600+ const normalizedHex = hexMatch[1].length === 3
601+ ? hexMatch[1].split('').map((value) => value + value).join('')
602+ : hexMatch[1];
603+ return {
604+ red: Number.parseInt(normalizedHex.slice(0, 2), 16),
605+ green: Number.parseInt(normalizedHex.slice(2, 4), 16),
606+ blue: Number.parseInt(normalizedHex.slice(4, 6), 16),
607+ };
608+ };
609+
610+ const resolveLegendButtonFill = function (opacity) {
611+ const referencePath = graphDiv.querySelector('.modebar-btn path');
612+ const referenceFill = referencePath ? window.getComputedStyle(referencePath).fill : null;
613+ const fontColor = graphDiv._fullLayout && graphDiv._fullLayout.font
614+ ? graphDiv._fullLayout.font.color
615+ : null;
616+ const parsedColor = (
617+ parseColor(referenceFill)
618+ || parseColor(fontColor)
619+ || {red: 68, green: 68, blue: 68}
620+ );
621+ return (
622+ 'rgba('
623+ + parsedColor.red
624+ + ', '
625+ + parsedColor.green
626+ + ', '
627+ + parsedColor.blue
628+ + ', '
629+ + opacity
630+ + ')'
631+ );
632+ };
633+
634+ const updateLegendButtonAppearance = function (legendVisible) {
635+ const legendButton = graphDiv.querySelector('[data-legend-toggle="true"]');
636+ if (!legendButton) {
637+ return;
638+ }
639+
640+ const legendIconPath = legendButton.querySelector('path');
641+ if (!legendIconPath) {
642+ return;
643+ }
644+
645+ legendButton.classList.toggle('active', legendVisible);
646+ legendButton.setAttribute('aria-pressed', String(legendVisible));
647+ legendIconPath.setAttribute(
648+ 'style',
649+ 'fill: ' + resolveLegendButtonFill(legendVisible ? 0.7 : 0.3) + ';',
650+ );
651+ };
652+
653+ const applyLegendVisibility = function (legendVisible) {
654+ const legend = graphDiv.querySelector('.legend');
655+ if (legend) {
656+ legend.style.display = legendVisible ? 'inline' : 'none';
657+ legend.style.visibility = legendVisible ? 'visible' : 'hidden';
658+ legend.style.pointerEvents = legendVisible ? '' : 'none';
659+ }
660+
661+ if (graphDiv.layout) {
662+ graphDiv.layout.showlegend = legendVisible;
663+ }
664+
665+ if (graphDiv._fullLayout) {
666+ graphDiv._fullLayout.showlegend = legendVisible;
667+ }
668+ };
669+
670+ const readLegendVisibility = function () {
671+ if (graphDiv.dataset.legendVisible === 'true') {
672+ return true;
673+ }
674+
675+ if (graphDiv.dataset.legendVisible === 'false') {
676+ return false;
677+ }
678+
679+ const legend = graphDiv.querySelector('.legend');
680+ if (legend) {
681+ return (
682+ window.getComputedStyle(legend).display !== 'none'
683+ && window.getComputedStyle(legend).visibility !== 'hidden'
684+ );
685+ }
686+
687+ if (graphDiv.layout && typeof graphDiv.layout.showlegend === 'boolean') {
688+ return graphDiv.layout.showlegend;
689+ }
690+
691+ if (graphDiv._fullLayout && typeof graphDiv._fullLayout.showlegend === 'boolean') {
692+ return graphDiv._fullLayout.showlegend;
693+ }
694+
695+ return true;
696+ };
697+
698+ const syncLegendVisibility = function (legendVisible) {
699+ const resolvedLegendVisible = typeof legendVisible === 'boolean'
700+ ? legendVisible
701+ : readLegendVisibility();
702+ graphDiv.dataset.legendVisible = String(resolvedLegendVisible);
703+ applyLegendVisibility(resolvedLegendVisible);
704+ updateLegendButtonAppearance(resolvedLegendVisible);
705+ return resolvedLegendVisible;
706+ };
707+
708+ const toggleLegend = function (event) {
709+ if (event) {
710+ event.preventDefault();
711+ event.stopPropagation();
712+ }
713+
714+ const currentValue = readLegendVisibility();
715+ const nextValue = !currentValue;
716+ syncLegendVisibility(nextValue);
717+ };
718+
719+ const installLegendToggleButton = function () {
720+ const modebar = graphDiv.querySelector('.modebar');
721+ if (!modebar) {
722+ return;
723+ }
724+
725+ if (!modebar.querySelector('.modebar-group')) {
726+ return;
727+ }
728+
729+ let legendButton = modebar.querySelector('[data-legend-toggle="true"]');
730+ if (!legendButton) {
731+ const legendButtonGroup = document.createElement('div');
732+ legendButtonGroup.className = 'modebar-group';
733+
734+ legendButton = document.createElement('a');
735+ legendButton.className = 'modebar-btn';
736+ legendButton.href = 'javascript:void(0)';
737+ legendButton.setAttribute('data-title', 'Toggle legend');
738+ legendButton.setAttribute('data-legend-toggle', 'true');
739+ legendButton.setAttribute('aria-label', 'Toggle legend');
740+ legendButton.setAttribute('role', 'button');
741+ legendButton.setAttribute('tabindex', '0');
742+ legendButton.innerHTML = [
743+ '<svg viewBox="0 0 1000 1000"'
744+ + ' class="icon" height="1em" width="1em"'
745+ + ' aria-hidden="true">',
746+ '<path d="M120 160H240V280H120z M120 440H240V560H120z '
747+ + 'M120 720H240V840H120z M320 200H880V240H320z '
748+ + 'M320 480H880V520H320z M320 760H880V800H320z"></path>',
749+ '</svg>',
750+ ].join('');
751+
752+ legendButtonGroup.appendChild(legendButton);
753+ modebar.appendChild(legendButtonGroup);
754+ }
755+
756+ legendButton.onclick = toggleLegend;
757+ legendButton.onkeydown = function (event) {
758+ if (event.key === 'Enter' || event.key === ' ') {
759+ toggleLegend(event);
760+ }
761+ };
762+
763+ syncLegendVisibility();
764+ };
765+
766+ if (graphDiv.on) {
767+ graphDiv.on('plotly_afterplot', installLegendToggleButton);
768+ graphDiv.on('plotly_relayout', function (eventData) {
769+ if (eventData && typeof eventData.showlegend === 'boolean') {
770+ syncLegendVisibility(eventData.showlegend);
771+ return;
772+ }
773+
774+ syncLegendVisibility();
775+ });
776+ }
777+ syncLegendVisibility();
778+ window.requestAnimationFrame(installLegendToggleButton);
779+ """
780+
563781 @staticmethod
564782 def _get_figure (
565783 data : object ,
@@ -587,6 +805,36 @@ def _get_figure(
587805 fig .update_yaxes (tickformat = ',.6~g' , separatethousands = True )
588806 return fig
589807
808+ @staticmethod
809+ def _has_visible_legend (fig : object ) -> bool :
810+ """Return whether a figure exposes at least one legend entry."""
811+
812+ def _trace_value (trace : object , field_name : str ) -> object :
813+ value = getattr (trace , field_name , None )
814+ if value is not None :
815+ return value
816+
817+ trace_kwargs = getattr (trace , 'kwargs' , None )
818+ if isinstance (trace_kwargs , dict ):
819+ return trace_kwargs .get (field_name )
820+
821+ return None
822+
823+ layout = getattr (fig , 'layout' , None )
824+ layout_showlegend = getattr (layout , 'showlegend' , None )
825+ if layout_showlegend is False :
826+ return False
827+
828+ for trace in getattr (fig , 'data' , ()):
829+ if _trace_value (trace , 'visible' ) is False :
830+ continue
831+ if _trace_value (trace , 'showlegend' ) is False :
832+ continue
833+ if _trace_value (trace , 'name' ):
834+ return True
835+
836+ return False
837+
590838 def _show_figure (
591839 self ,
592840 fig : object ,
@@ -607,16 +855,21 @@ def _show_figure(
607855 if in_pycharm () or display is None or HTML is None :
608856 fig .show (config = config )
609857 else :
858+ post_script = None
859+ if self ._has_visible_legend (fig ):
860+ post_script = self ._modebar_legend_toggle_post_script ()
610861 html_fig = pio .to_html (
611862 fig ,
612863 include_plotlyjs = 'cdn' ,
613864 full_html = False ,
614865 config = config ,
866+ post_script = post_script ,
615867 )
616868 display (HTML (html_fig ))
617869
618- @staticmethod
870+ @classmethod
619871 def _get_layout (
872+ cls ,
620873 title : str ,
621874 axes_labels : object ,
622875 shapes : list | None = None ,
@@ -649,6 +902,7 @@ def _get_layout(
649902 'text' : title ,
650903 },
651904 legend = {
905+ 'bgcolor' : cls ._legend_background_color (),
652906 'xanchor' : 'right' ,
653907 'x' : 1.0 ,
654908 'yanchor' : 'top' ,
@@ -1041,6 +1295,7 @@ def plot_powder_meas_vs_calc(
10411295 },
10421296 title = {'text' : plot_spec .title },
10431297 legend = {
1298+ 'bgcolor' : self ._legend_background_color (),
10441299 'xanchor' : 'right' ,
10451300 'x' : 1.0 ,
10461301 'yanchor' : 'top' ,
0 commit comments