Skip to content

Commit 27407d2

Browse files
Improve Plotly legend controls and modebar visibility (#167)
* Add legend toggle button to Plotly modebar * Always show Plotly mode bar * Add transparency to legend background in Plotly * Fix formatting
1 parent ca825c4 commit 27407d2

2 files changed

Lines changed: 407 additions & 3 deletions

File tree

src/easydiffraction/display/plotters/plotly.py

Lines changed: 256 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)