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`: