diff --git a/Orange/widgets/visualize/icons/interval-horizontal.svg b/Orange/widgets/visualize/icons/interval-horizontal.svg new file mode 100644 index 00000000000..3408fcc03eb --- /dev/null +++ b/Orange/widgets/visualize/icons/interval-horizontal.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/Orange/widgets/visualize/icons/interval-vertical.svg b/Orange/widgets/visualize/icons/interval-vertical.svg new file mode 100644 index 00000000000..eaa6d143473 --- /dev/null +++ b/Orange/widgets/visualize/icons/interval-vertical.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/Orange/widgets/visualize/owscatterplot.py b/Orange/widgets/visualize/owscatterplot.py index dcbfb87d3ad..510aee35d02 100644 --- a/Orange/widgets/visualize/owscatterplot.py +++ b/Orange/widgets/visualize/owscatterplot.py @@ -1,5 +1,5 @@ import math -from typing import List, Callable +from typing import List, Callable, Optional from xml.sax.saxutils import escape import numpy as np @@ -9,11 +9,12 @@ from sklearn.metrics import r2_score from AnyQt.QtCore import Qt, QTimer, QPointF -from AnyQt.QtGui import QColor, QFont -from AnyQt.QtWidgets import QGroupBox +from AnyQt.QtGui import QColor, QFont, QFontMetrics +from AnyQt.QtWidgets import QGroupBox, QSizePolicy, QPushButton import pyqtgraph as pg +from orangewidget.utils import load_styled_icon from orangewidget.utils.combobox import ComboBoxSearch from Orange.data import Table, Domain, DiscreteVariable, Variable @@ -29,6 +30,7 @@ from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase, \ ScatterBaseParameterSetter +from Orange.widgets.visualize.utils.error_bars_dialog import ErrorBarsDialog from Orange.widgets.visualize.utils.vizrank import VizRankDialogAttrPair, \ VizRankMixin from Orange.widgets.visualize.utils.customizableplot import Updater @@ -150,15 +152,20 @@ def __init__(self, scatter_widget, parent): self.parameter_setter = ParameterSetter(self) self.reg_line_items = [] self.ellipse_items: List[pg.PlotCurveItem] = [] + self.error_bars_items: List[pg.ErrorBarItem] = [] + self.view_box.sigResized.connect(self.update_error_bars) + self.view_box.sigRangeChanged.connect(self.update_error_bars) def clear(self): super().clear() self.reg_line_items.clear() self.ellipse_items.clear() + self.error_bars_items.clear() def update_coordinates(self): super().update_coordinates() self.update_axes() + self.update_error_bars() # Don't update_regression line here: update_coordinates is always # followed by update_point_props, which calls update_colors @@ -168,6 +175,9 @@ def update_colors(self): self.update_ellipse() def jitter_coordinates(self, x, y): + if self.jitter_size == 0: + return x, y + def get_span(attr): if attr.is_discrete: # Assuming the maximal jitter size is 10, a span of 4 will @@ -179,7 +189,7 @@ def get_span(attr): return 0 # No jittering span_x = get_span(self.master.attr_x) span_y = get_span(self.master.attr_y) - if self.jitter_size == 0 or (span_x == 0 and span_y == 0): + if span_x == 0 and span_y == 0: return x, y return self._jitter_data(x, y, span_x, span_y) @@ -333,6 +343,42 @@ def _add_ellipse(self, x: np.ndarray, y: np.ndarray, color: QColor) -> np.ndarra self.plot_widget.addItem(ellipse) self.ellipse_items.append(ellipse) + def update_jittering(self): + super().update_jittering() + self.update_error_bars() + + def update_error_bars(self): + for item in self.error_bars_items: + self.plot_widget.removeItem(item) + self.error_bars_items.clear() + if not self.master.can_draw_regression_line(): + return + + x, y = self.get_coordinates() + if x is None: + return + + top, bottom, left, right = self.master.get_errors_data() + if top is None and bottom is None and left is None and right is None: + return + + px, py = self.view_box.viewPixelSize() + pen = pg.mkPen(color=QColor("#505050")) + + # x axis + error_bars = pg.ErrorBarItem(x=x, y=y, left=left, right=right, + beam=py * 10, pen=pen) + error_bars.setZValue(-1) + self.plot_widget.addItem(error_bars) + self.error_bars_items.append(error_bars) + + # y axis + error_bars = pg.ErrorBarItem(x=x, y=y, top=top, bottom=bottom, + beam=px * 10, pen=pen) + error_bars.setZValue(-1) + self.plot_widget.addItem(error_bars) + self.error_bars_items.append(error_bars) + class OWScatterPlot(OWDataProjectionWidget, VizRankMixin(ScatterPlotVizRank)): """Scatterplot visualization with explorative analysis and intelligent @@ -355,6 +401,12 @@ class Outputs(OWDataProjectionWidget.Outputs): auto_sample = Setting(True) attr_x = ContextSetting(None) attr_y = ContextSetting(None) + attr_x_upper = ContextSetting(None) + attr_x_lower = ContextSetting(None) + attr_x_is_abs = Setting(False) + attr_y_upper = ContextSetting(None) + attr_y_lower = ContextSetting(None) + attr_y_is_abs = Setting(False) tooltip_shows_all = Setting(True) GRAPH_CLASS = OWScatterPlotGraph @@ -376,6 +428,10 @@ def __init__(self): self.xy_model: DomainModel = None self.cb_attr_x: ComboBoxSearch = None self.cb_attr_y: ComboBoxSearch = None + self.button_attr_x: QPushButton = None + self.button_attr_y: QPushButton = None + self.__x_axis_dlg: ErrorBarsDialog = None + self.__y_axis_dlg: ErrorBarsDialog = None self.sampling: QGroupBox = None self._xy_invalidated: bool = True @@ -425,21 +481,71 @@ def _add_controls_axis(self): spacing=2 if gui.is_macstyle() else 8) dmod = DomainModel self.xy_model = DomainModel(dmod.MIXED, valid_types=dmod.PRIMITIVE) + + hor_icon, ver_icon = self.__get_bar_icons() + width = 3 * QFontMetrics(self.font()).horizontalAdvance("m") + hbox = gui.hBox(self.attr_box, spacing=0) self.cb_attr_x = gui.comboBox( - self.attr_box, self, "attr_x", label="Axis x:", + hbox, self, "attr_x", label="Axis x:", callback=self.set_attr_from_combo, model=self.xy_model, **common_options, ) + self.button_attr_x = gui.button( + hbox, self, "", callback=self.__on_x_button_clicked, + autoDefault=False, width=width, enabled=False, + sizePolicy=(QSizePolicy.Fixed, QSizePolicy.Fixed) + ) + self.button_attr_x.setIcon(hor_icon) + + hbox = gui.hBox(self.attr_box, spacing=0) self.cb_attr_y = gui.comboBox( - self.attr_box, self, "attr_y", label="Axis y:", + hbox, self, "attr_y", label="Axis y:", callback=self.set_attr_from_combo, model=self.xy_model, **common_options, ) + self.button_attr_y = gui.button( + hbox, self, "", callback=self.__on_y_button_clicked, + autoDefault=False, width=width, enabled=False, + sizePolicy=(QSizePolicy.Fixed, QSizePolicy.Fixed) + ) + self.button_attr_y.setIcon(ver_icon) + vizrank_box = gui.hBox(self.attr_box) button = self.vizrank_button("Find Informative Projections") vizrank_box.layout().addWidget(button) self.vizrankSelectionChanged.connect(self.set_attr) + self.__x_axis_dlg = ErrorBarsDialog(self) + self.__x_axis_dlg.changed.connect(self.__on_x_dlg_changed) + self.__y_axis_dlg = ErrorBarsDialog(self) + self.__y_axis_dlg.changed.connect(self.__on_y_dlg_changed) + + def __on_x_button_clicked(self): + self.__show_bars_dlg( + self.__x_axis_dlg, self.button_attr_x, + self.attr_x_upper, self.attr_x_lower, self.attr_x_is_abs) + + def __on_y_button_clicked(self): + self.__show_bars_dlg( + self.__y_axis_dlg, self.button_attr_y, + self.attr_y_upper, self.attr_y_lower, self.attr_y_is_abs) + + def __show_bars_dlg(self, dlg, button, upper, lower, is_abs): + pos = button.mapToGlobal(button.rect().bottomLeft()) + dlg.show_dlg(self.data.domain, + pos.x(), pos.y(), + upper, lower, is_abs) + + def __on_x_dlg_changed(self): + self.attr_x_upper, self.attr_x_lower, self.attr_x_is_abs = \ + self.__x_axis_dlg.get_data() + self.graph.update_error_bars() + + def __on_y_dlg_changed(self): + self.attr_y_upper, self.attr_y_lower, self.attr_y_is_abs = \ + self.__y_axis_dlg.get_data() + self.graph.update_error_bars() + def _add_controls_sampling(self): self.sampling = gui.auto_commit( self.controlArea, self, "auto_sample", "Sample", box="Sampling", @@ -447,15 +553,22 @@ def _add_controls_sampling(self): self.sampling.setVisible(False) @property - def effective_variables(self): - return [self.attr_x, self.attr_y] if self.attr_x and self.attr_y else [] + def effective_variables(self) -> list[Variable]: + variables = [] + if self.attr_x and self.attr_y: + variables.append(self.attr_x) + if self.attr_x.name != self.attr_y.name: + variables.append(self.attr_y) + for var in (self.attr_x_upper, self.attr_x_lower, + self.attr_y_upper, self.attr_y_lower): + # set is not used to preserve order + if var and var not in variables: + variables.append(var) + return variables @property def effective_data(self): - eff_var = self.effective_variables - if eff_var and self.attr_x.name == self.attr_y.name: - eff_var = [self.attr_x] - return self.data.transform(Domain(eff_var)) + return self.data.transform(Domain(self.effective_variables)) def init_vizrank(self): err_msg = "" @@ -523,6 +636,14 @@ def check_data(self): len(self.data.domain.variables) == 0): self.data = None + def enable_controls(self): + super().enable_controls() + enabled = bool(self.data) and \ + self.data.domain.has_continuous_attributes(include_class=True, + include_metas=True) + self.button_attr_x.setEnabled(enabled) + self.button_attr_y.setEnabled(enabled) + def get_embedding(self): self.valid_data = None if self.data is None: @@ -541,6 +662,31 @@ def get_embedding(self): msg.missing_coords(self.attr_x.name, self.attr_y.name) return np.vstack((x_data, y_data)).T + def get_errors_data(self) -> tuple[ + Optional[np.ndarray], Optional[np.ndarray], + Optional[np.ndarray], Optional[np.ndarray] + ]: + x_data = self.get_column(self.attr_x) + y_data = self.get_column(self.attr_y) + top, bottom, left, right = [None] * 4 + if self.attr_x_upper: + right = self.get_column(self.attr_x_upper) + if self.attr_x_is_abs: + right = right - x_data + if self.attr_x_lower: + left = self.get_column(self.attr_x_lower) + if self.attr_x_is_abs: + left = x_data - left + if self.attr_y_upper: + top = self.get_column(self.attr_y_upper) + if self.attr_y_is_abs: + top = top - y_data + if self.attr_y_lower: + bottom = self.get_column(self.attr_y_lower) + if self.attr_y_is_abs: + bottom = y_data - bottom + return top, bottom, left, right + # Tooltip def _point_tooltip(self, point_id, skip_attrs=()): point_data = self.data[point_id] @@ -580,6 +726,8 @@ def init_attr_values(self): self.attr_x = self.xy_model[0] if self.xy_model else None self.attr_y = self.xy_model[1] if len(self.xy_model) >= 2 \ else self.attr_x + self.attr_x_upper, self.attr_x_lower = None, None + self.attr_y_upper, self.attr_y_lower = None, None def switch_sampling(self): self.__timer.stop() @@ -588,15 +736,15 @@ def switch_sampling(self): self.__timer.start() @OWDataProjectionWidget.Inputs.data_subset - def set_subset_data(self, subset_data): + def set_subset_data(self, subset: Optional[Table]): self.warning() - if isinstance(subset_data, SqlTable): - if subset_data.approx_len() < AUTO_DL_LIMIT: - subset_data = Table(subset_data) + if isinstance(subset, SqlTable): + if subset.approx_len() < AUTO_DL_LIMIT: + subset = Table(subset) else: self.warning("Data subset does not support large Sql tables") - subset_data = None - super().set_subset_data(subset_data) + subset = None + super().set_subset_data(subset) # called when all signals are received, so the graph is updated only once def handleNewSignals(self): @@ -608,12 +756,17 @@ def handleNewSignals(self): self.attr_x, self.attr_y = self.attribute_selection_list else: self.attr_x, self.attr_y = None, None + self.attr_x_upper, self.attr_x_lower = None, None + self.attr_y_upper, self.attr_y_lower = None, None self._invalidated = self._invalidated or self._xy_invalidated self._xy_invalidated = False super().handleNewSignals() if self._domain_invalidated: self.graph.update_axes() + self.graph.update_error_bars() self._domain_invalidated = False + if self.attribute_selection_list: + self.graph.update_error_bars() can_plot = self.can_draw_regression_line() self.cb_reg_line.setEnabled(can_plot) self.graph.controls.show_ellipse.setEnabled(can_plot) @@ -706,6 +859,18 @@ def migrate_context(cls, context, version): if values["attr_x"][1] % 100 == 1 or values["attr_y"][1] % 100 == 1: raise IncompatibleContext() + __HorizontalBarIcon = None + __VerticalBarIcon = None + + @classmethod + def __get_bar_icons(cls): + if cls.__HorizontalBarIcon is None: + cls.__HorizontalBarIcon = load_styled_icon( + "Orange.widgets.visualize", "icons/interval-horizontal.svg") + cls.__VerticalBarIcon = load_styled_icon( + "Orange.widgets.visualize", "icons/interval-vertical.svg") + return cls.__HorizontalBarIcon, cls.__VerticalBarIcon + if __name__ == "__main__": # pragma: no cover table = Table("iris") diff --git a/Orange/widgets/visualize/tests/test_owscatterplot.py b/Orange/widgets/visualize/tests/test_owscatterplot.py index f8a650f960a..453e4f6c03e 100644 --- a/Orange/widgets/visualize/tests/test_owscatterplot.py +++ b/Orange/widgets/visualize/tests/test_owscatterplot.py @@ -1257,6 +1257,230 @@ def test_visual_settings(self, timeout=DEFAULT_TIMEOUT): for item in graph.ellipse_items: self.assertEqual(item.opts["pen"].width(), 10) + def test_error_bars_enabled(self): + self.assertFalse(self.widget.button_attr_x.isEnabled()) + self.assertFalse(self.widget.button_attr_y.isEnabled()) + self.send_signal(self.widget.Inputs.data, self.data) + self.assertTrue(self.widget.button_attr_x.isEnabled()) + self.assertTrue(self.widget.button_attr_y.isEnabled()) + self.send_signal(self.widget.Inputs.data, Table("zoo")) + self.assertFalse(self.widget.button_attr_x.isEnabled()) + self.assertFalse(self.widget.button_attr_y.isEnabled()) + + def test_error_bars(self): + data = Table("iris") + var = ContinuousVariable("ϵ") + data = data.add_column(var, np.full(150, 0.1)) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var + self.widget.attr_x_lower = var + self.widget.attr_y_upper = var + self.widget.attr_y_lower = var + + graph = self.widget.graph + graph.reset_graph() + self.assertEqual(len(graph.error_bars_items), 2) + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(len(graph.error_bars_items), 0) + + def test_error_bars_missing_values(self): + data = Table("iris") + with data.unlocked(): + data.X[0, 0] = np.nan + data.X[1, 0] = np.nan + data = data[:4] + var = ContinuousVariable("ϵ") + data = data.add_column(var, np.array([0.1, np.nan, 0.1, np.nan])) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var + self.widget.attr_x_lower = var + self.widget.attr_y_upper = var + self.widget.attr_y_lower = var + + graph = self.widget.graph + graph.reset_graph() + self.assertEqual(len(graph.error_bars_items), 2) + self.assertEqual(len(graph.scatterplot_item.data), 2) + self.assertEqual(len(graph.error_bars_items[0].opts["left"]), 2) + + def test_error_bars_jitter(self): + data = Table("iris") + var = ContinuousVariable("ϵ") + data = data.add_column(var, np.full(150, 0.1)) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var + self.widget.attr_x_lower = var + + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["left"][:3]), [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["right"][:3]), [0.1] * 3) + + + self.widget.graph.controls.jitter_continuous.setChecked(True) + self.widget.graph.controls.jitter_size.setValue(10) + self.widget.graph.reset_graph() + + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3].round(1)), + [5.2, 5.1, 4.8]) + self.assertEqual(list(error_bar_item.opts["y"][:3].round(1)), + [3.6, 2.9, 3.3]) + self.assertEqual(list(error_bar_item.opts["left"][:3]), [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["right"][:3]), [0.1] * 3) + + def test_error_bars_abs_values(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 0] + 0.1) + data = data.add_column(var_lower, data.X[:, 0] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var_upper + self.widget.attr_x_lower = var_lower + self.widget.attr_x_is_abs = True + + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["left"][:3].round(1)), + [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["right"][:3].round(1)), + [0.1] * 3) + + def test_error_bars_button_clicked(self): + data = Table("iris") + var1 = ContinuousVariable("ϵ1") + var2 = ContinuousVariable("ϵ2") + var3 = ContinuousVariable("ϵ3") + var4 = ContinuousVariable("ϵ4") + data = data.add_column(var1, np.full(150, 0.1)) + data = data.add_column(var2, np.full(150, 0.1)) + data = data.add_column(var3, np.full(150, 0.1)) + data = data.add_column(var4, np.full(150, 0.1)) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var1 + self.widget.attr_x_lower = var2 + self.widget.attr_y_upper = var3 + self.widget.attr_y_lower = var4 + + x_dlg = self.widget._OWScatterPlot__x_axis_dlg + x_dlg._set_data = Mock() + x_dlg.show = Mock() + x_dlg.raise_ = Mock() + x_dlg.activateWindow = Mock() + self.widget.button_attr_x.click() + x_dlg._set_data.assert_called_with(data.domain, var1, var2, False) + + y_dlg = self.widget._OWScatterPlot__y_axis_dlg + y_dlg._set_data = Mock() + y_dlg.show = Mock() + y_dlg.raise_ = Mock() + y_dlg.activateWindow = Mock() + self.widget.button_attr_y.click() + y_dlg._set_data.assert_called_with(data.domain, var3, var4, False) + + def test_error_bars_dlg_changed(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 1] + 0.2) + data = data.add_column(var_lower, data.X[:, 1] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_y_upper = var_upper + self.widget.attr_y_lower = var_lower + + y_dlg = self.widget._OWScatterPlot__y_axis_dlg + y_dlg.show = Mock() + y_dlg.raise_ = Mock() + y_dlg.activateWindow = Mock() + self.widget.button_attr_y.click() + y_dlg._ErrorBarsDialog__radio_buttons.buttons()[1].click() + + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[1] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["top"][:3].round(1)), + [0.2] * 3) + self.assertEqual(list(error_bar_item.opts["bottom"][:3].round(1)), + [0.1] * 3) + + def test_error_bars_saved_settings(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 0] + 0.2) + data = data.add_column(var_lower, data.X[:, 0] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var_upper + self.widget.attr_x_lower = var_lower + self.widget.attr_x_is_abs = True + + settings = self.widget.settingsHandler.pack_data(self.widget) + widget = self.create_widget(OWScatterPlot, stored_settings=settings) + self.send_signal(widget.Inputs.data, data, widget=widget) + + widget.graph.reset_graph() + error_bar_item = widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["right"][:3].round(1)), + [0.2] * 3) + self.assertEqual(list(error_bar_item.opts["left"][:3].round(1)), + [0.1] * 3) + + def test_error_bars_change_domain(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 0] + 0.1) + data = data.add_column(var_lower, data.X[:, 0] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var_upper + self.widget.attr_x_lower = var_lower + self.widget.attr_x_is_abs = True + + _data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper_") + var_lower = ContinuousVariable("ϵ_lower_") + _data = _data.add_column(var_upper, _data.X[:, 0] + 0.1) + _data = _data.add_column(var_lower, _data.X[:, 0] - 0.1) + self.send_signal(self.widget.Inputs.data, _data) + self.assertEqual(self.widget.graph.error_bars_items, []) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["right"][:3].round(1)), + [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["left"][:3].round(1)), + [0.1] * 3) + if __name__ == "__main__": import unittest diff --git a/Orange/widgets/visualize/utils/error_bars_dialog.py b/Orange/widgets/visualize/utils/error_bars_dialog.py new file mode 100644 index 00000000000..bfb96b4222e --- /dev/null +++ b/Orange/widgets/visualize/utils/error_bars_dialog.py @@ -0,0 +1,125 @@ +import sys +from typing import Optional + +from AnyQt.QtCore import Signal, Qt +from AnyQt.QtWidgets import QVBoxLayout, QWidget, QComboBox, \ + QFormLayout, QLabel, QButtonGroup, QRadioButton, QLayout + +from Orange.data import ContinuousVariable, Domain +from Orange.widgets.utils import disconnected +from Orange.widgets.utils.itemmodels import DomainModel + + +class ErrorBarsDialog(QWidget): + changed = Signal() + + def __init__( + self, + parent: QWidget, + ): + super().__init__(parent) + self.setWindowFlags(self.windowFlags() | Qt.Popup) + self.hide() + self.__model = DomainModel( + separators=False, + valid_types=(ContinuousVariable,), + placeholder="(None)" + ) + + self.__upper_combo = upper_combo = QComboBox() + upper_combo.setMinimumWidth(200) + upper_combo.setModel(self.__model) + upper_combo.currentIndexChanged.connect(self.changed) + + self.__lower_combo = lower_combo = QComboBox() + lower_combo.setMinimumWidth(200) + lower_combo.setModel(self.__model) + lower_combo.currentIndexChanged.connect(self.changed) + + button_diff = QRadioButton("Difference from plotted value", + checked=True) + button_abs = QRadioButton("Absolute position on the plot") + self.__radio_buttons = QButtonGroup() + self.__radio_buttons.addButton(button_diff, 0) + self.__radio_buttons.addButton(button_abs, 1) + self.__radio_buttons.buttonClicked.connect(self.changed) + + form = QFormLayout() + form.addRow(QLabel("Upper:"), upper_combo) + form.addRow(QLabel("Lower:"), lower_combo) + form.setVerticalSpacing(10) + form.addRow(button_diff) + form.addRow(button_abs) + + layout = QVBoxLayout() + self.setLayout(layout) + layout.addLayout(form) + layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + + def get_data(self) -> tuple[ + Optional[ContinuousVariable], Optional[ContinuousVariable], bool + ]: + upper_var, lower_var = None, None + if self.__model: + upper_var = self.__model[self.__upper_combo.currentIndex()] + lower_var = self.__model[self.__lower_combo.currentIndex()] + return upper_var, lower_var, bool(self.__radio_buttons.checkedId()) + + def show_dlg( + self, + domain: Domain, + x: int, y: int, + attr_upper: Optional[ContinuousVariable] = None, + attr_lower: Optional[ContinuousVariable] = None, + is_abs: bool = True + ): + self._set_data(domain, attr_upper, attr_lower, is_abs) + self.show() + self.raise_() + self.move(x, y) + self.activateWindow() + + def _set_data( + self, + domain: Domain, + upper_attr: Optional[ContinuousVariable], + lower_attr: Optional[ContinuousVariable], + is_abs: bool + ): + upper_combo, lower_combo = self.__upper_combo, self.__lower_combo + with disconnected(upper_combo.currentIndexChanged, self.changed): + with disconnected(lower_combo.currentIndexChanged, self.changed): + self.__model.set_domain(domain) + upper_combo.setCurrentIndex(self.__model.indexOf(upper_attr)) + lower_combo.setCurrentIndex(self.__model.indexOf(lower_attr)) + self.__radio_buttons.buttons()[int(is_abs)].setChecked(True) + + +if __name__ == "__main__": + # pylint: disable=ungrouped-imports + from AnyQt.QtWidgets import QApplication, QPushButton + + from Orange.data import Table + + app = QApplication(sys.argv) + w = QWidget() + w.setFixedSize(400, 200) + + dlg = ErrorBarsDialog(w) + dlg.changed.connect(lambda: print(dlg.get_data())) + + btn = QPushButton(w) + btn.setText("Open") + + _domain: Domain = Table("iris").domain + + + def _on_click(): + dlg.show_dlg(_domain, 500, 500, _domain.attributes[2], + _domain.attributes[3], is_abs=False) + + + btn.clicked.connect(_on_click) + + w.show() + sys.exit(app.exec()) diff --git a/Orange/widgets/visualize/utils/tests/test_error_bars_dialog.py b/Orange/widgets/visualize/utils/tests/test_error_bars_dialog.py new file mode 100644 index 00000000000..a092331c17d --- /dev/null +++ b/Orange/widgets/visualize/utils/tests/test_error_bars_dialog.py @@ -0,0 +1,86 @@ +# pylint: disable=protected-access +import unittest +from unittest.mock import Mock + +from orangewidget.tests.base import GuiTest + +from Orange.data import Table +from Orange.widgets.visualize.utils.error_bars_dialog import ErrorBarsDialog + + +class TestErrorBarsDialog(GuiTest): + def setUp(self) -> None: + self._dlg = ErrorBarsDialog(None) + + def test_init(self): + form = self._dlg.layout().itemAt(0) + + self.assertEqual(form.itemAt(0).widget().text(), "Upper:") + self.assertEqual(form.itemAt(1).widget().currentText(), "(None)") + + self.assertEqual(form.itemAt(2).widget().text(), "Lower:") + self.assertEqual(form.itemAt(3).widget().currentText(), "(None)") + + self.assertEqual(form.itemAt(4).widget().text(), + "Difference from plotted value") + self.assertTrue(form.itemAt(4).widget().isChecked()) + + self.assertEqual(form.itemAt(5).widget().text(), + "Absolute position on the plot") + self.assertFalse(form.itemAt(5).widget().isChecked()) + + def test_get_data(self): + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIsNone(upper_var) + self.assertIsNone(lower_var) + self.assertFalse(is_abs) + + def test_set_data(self): + data = Table("iris") + + self._dlg._set_data(data.domain, data.domain.attributes[2], + data.domain.attributes[1], True) + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIs(upper_var, data.domain.attributes[2]) + self.assertIs(lower_var, data.domain.attributes[1]) + self.assertTrue(is_abs) + + self._dlg._set_data(data.domain, None, None, True) + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIsNone(upper_var) + self.assertIsNone(lower_var) + self.assertTrue(is_abs) + + def test_set_data_none(self): + self._dlg._set_data(None, None, None, False) + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIsNone(upper_var) + self.assertIsNone(lower_var) + self.assertFalse(is_abs) + + def test_set_data_err(self): + data = Table("iris") + self.assertRaises(ValueError, self._dlg._set_data, data.domain, + data.domain.class_var, data.domain.class_var, False) + + def test_changed(self): + data = Table("iris") + mock = Mock() + self._dlg.changed.connect(mock) + self._dlg._set_data(data.domain, data.domain.attributes[2], + data.domain.attributes[1], True) + + self._dlg._ErrorBarsDialog__upper_combo.setCurrentIndex(1) + mock.assert_called_once() + + mock.reset_mock() + self._dlg._ErrorBarsDialog__lower_combo.setCurrentIndex(0) + mock.assert_called_once() + + mock.reset_mock() + self._dlg._ErrorBarsDialog__radio_buttons.buttons()[0].click() + mock.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/i18n/si/msgs.jaml b/i18n/si/msgs.jaml index eec09e4d7d4..c8d819fe7ff 100644 --- a/i18n/si/msgs.jaml +++ b/i18n/si/msgs.jaml @@ -202,9 +202,9 @@ util.py: def `funcv`: unsafe: false version.py: - 3.38.0: false - 3.38.0.dev0+b3dd2eb: false - b3dd2eba6a3cfa73ba06460226d6e16a8e25a23a: false + 3.39.0: false + 3.39.0.dev0+c2c1648: false + c2c16487816d19a63177fae0b9febf1f0220f982: false .dev: false canvas/__main__.py: ORANGE_STATISTICS_API_URL: false @@ -14661,6 +14661,8 @@ widgets/visualize/owscatterplot.py: label: false def `_update_curve`: '#505050': true + def `update_error_bars`: + '#505050': true class `OWScatterPlot`: Scatter Plot: Razsevni diagram 'Interactive scatter plot visualization with ': Interaktivni prikaz podatkov z razsevnim diagramom. @@ -14687,6 +14689,7 @@ widgets/visualize/owscatterplot.py: Hotelling's T² confidence ellipse (α=95%): Elipsa zaupanja po Hotellingu (α=95%) def `_add_controls_axis`: Axes: Osi + m: false attr_x: false Axis x:: Os x: attr_y: false @@ -14734,6 +14737,10 @@ widgets/visualize/owscatterplot.py: attr_label: false attr_x: false attr_y: false + def `__get_bar_icons`: + Orange.widgets.visualize: false + icons/interval-horizontal.svg: false + icons/interval-vertical.svg: false __main__: false iris: false widgets/visualize/owscatterplotgraph.py: @@ -15412,6 +15419,18 @@ widgets/visualize/utils/customizableplot.py: def `__init__`: bottom: false left: false +widgets/visualize/utils/error_bars_dialog.py: + class `ErrorBarsDialog`: + def `__init__`: + (None): (Brez) + Difference from plotted value: Odmik od vrednosti + Absolute position on the plot: Absolutni položaj na grafu + Upper:: Zgornje: + Lower:: Spodnje: + __main__: false + Error Bars: false + Open: false + iris: false widgets/visualize/utils/heatmap.py: class `ColorMap`: def `replace`: