Repository: biolab/orange3

Issue:
Lift Curve: Add Visual Settings
<!-- 
Thanks for taking the time to submit a feature request!
For the best chance at our team considering your request, please answer the following questions to the best of your ability.
-->

**What's your use case?**
<!-- In other words, what's your pain point? -->
<!-- Is your request related to a problem, or perhaps a frustration? -->
<!-- Tell us the story that led you to write this request. -->

Lift Curve is a line-based graph, which could benefit from having visual settings.



**What's your proposed solution?**
<!-- Be specific, clear, and concise. -->

Add visual settings as found in Line Plot, for example.



**Are there any alternative solutions?**

Die trying to set line width with Inkscape.






Discussion:

Resolving patch:
diff --git a/Orange/widgets/evaluate/owliftcurve.py b/Orange/widgets/evaluate/owliftcurve.py
--- a/Orange/widgets/evaluate/owliftcurve.py
+++ b/Orange/widgets/evaluate/owliftcurve.py
@@ -9,6 +9,7 @@
 
 import pyqtgraph as pg
 
+from orangewidget.utils.visual_settings_dlg import VisualSettingsDialog
 from orangewidget.widget import Msg
 
 import Orange
@@ -19,6 +20,9 @@
 from Orange.widgets.utils import colorpalettes
 from Orange.widgets.evaluate.owrocanalysis import convex_hull
 from Orange.widgets.utils.widgetpreview import WidgetPreview
+from Orange.widgets.visualize.utils.customizableplot import Updater, \
+    CommonParameterSetter
+from Orange.widgets.visualize.utils.plotutils import AxisItem
 from Orange.widgets.widget import Input
 from Orange.widgets import report
 
@@ -43,6 +47,106 @@ class CurveTypes(IntEnum):
     LiftCurve, CumulativeGains = range(2)
 
 
+class ParameterSetter(CommonParameterSetter):
+    WIDE_LINE_LABEL = "Wide line"
+    LINE_LABEL = "Thin Line"
+    DEFAULT_LINE_LABEL = "Default Line"
+
+    WIDE_LINE_WIDTH = 3
+    LINE_WIDTH = 1
+    DEFAULT_LINE_WIDTH = 1
+
+    WIDE_LINE_STYLE = "Solid line"
+    LINE_STYLE = "Solid line"
+    DEFAULT_LINE_STYLE = "Dash line"
+
+    def __init__(self, master):
+        self.master = master
+        self.wide_line_settings = {
+            Updater.WIDTH_LABEL: self.WIDE_LINE_WIDTH,
+            Updater.STYLE_LABEL: self.WIDE_LINE_STYLE,
+        }
+        self.line_settings = {
+            Updater.WIDTH_LABEL: self.LINE_WIDTH,
+            Updater.STYLE_LABEL: self.LINE_STYLE,
+        }
+        self.default_line_settings = {
+            Updater.WIDTH_LABEL: self.DEFAULT_LINE_WIDTH,
+            Updater.STYLE_LABEL: self.DEFAULT_LINE_STYLE,
+        }
+        super().__init__()
+
+    def update_setters(self):
+        self.initial_settings = {
+            self.LABELS_BOX: {
+                self.FONT_FAMILY_LABEL: self.FONT_FAMILY_SETTING,
+                self.TITLE_LABEL: self.FONT_SETTING,
+                self.AXIS_TITLE_LABEL: self.FONT_SETTING,
+                self.AXIS_TICKS_LABEL: self.FONT_SETTING,
+            },
+            self.ANNOT_BOX: {
+                self.TITLE_LABEL: {self.TITLE_LABEL: ("", "")},
+            },
+            self.PLOT_BOX: {
+                self.WIDE_LINE_LABEL: {
+                    Updater.WIDTH_LABEL: (range(1, 15), self.WIDE_LINE_WIDTH),
+                    Updater.STYLE_LABEL: (list(Updater.LINE_STYLES),
+                                          self.WIDE_LINE_STYLE),
+                },
+                self.LINE_LABEL: {
+                    Updater.WIDTH_LABEL: (range(1, 15), self.LINE_WIDTH),
+                    Updater.STYLE_LABEL: (list(Updater.LINE_STYLES),
+                                          self.LINE_STYLE),
+                },
+                self.DEFAULT_LINE_LABEL: {
+                    Updater.WIDTH_LABEL: (range(1, 15),
+                                          self.DEFAULT_LINE_WIDTH),
+                    Updater.STYLE_LABEL: (list(Updater.LINE_STYLES),
+                                          self.DEFAULT_LINE_STYLE),
+                },
+            }
+        }
+
+        def update_wide_curves(**_settings):
+            self.wide_line_settings.update(**_settings)
+            if self.master.display_convex_hull:
+                Updater.update_lines(self.master.hull_items,
+                                     **self.wide_line_settings)
+            else:
+                Updater.update_lines(self.master.curve_items,
+                                     **self.wide_line_settings)
+
+        def update_thin_curves(**_settings):
+            self.line_settings.update(**_settings)
+            if self.master.display_convex_hull:
+                Updater.update_lines(self.master.curve_items,
+                                     **self.line_settings)
+
+        def update_default_line(**_settings):
+            self.default_line_settings.update(**_settings)
+            Updater.update_lines(self.default_line_items,
+                                 **self.default_line_settings)
+
+        self._setters[self.PLOT_BOX] = {
+            self.WIDE_LINE_LABEL: update_wide_curves,
+            self.LINE_LABEL: update_thin_curves,
+            self.DEFAULT_LINE_LABEL: update_default_line,
+        }
+
+    @property
+    def title_item(self):
+        return self.master.titleLabel
+
+    @property
+    def axis_items(self):
+        return [value["item"] for value in self.master.axes.values()]
+
+    @property
+    def default_line_items(self):
+        return [self.master.default_line_item] \
+            if self.master.default_line_item else []
+
+
 class OWLiftCurve(widget.OWWidget):
     name = "Lift Curve"
     description = "Construct and display a lift curve " \
@@ -71,6 +175,7 @@ class Error(widget.OWWidget.Error):
 
     display_convex_hull = settings.Setting(True)
     curve_type = settings.Setting(CurveTypes.LiftCurve)
+    visual_settings = settings.Setting({}, schema_only=True)
 
     graph_name = "plot"
 
@@ -112,7 +217,14 @@ def __init__(self):
         self.plotview = pg.GraphicsView(background="w")
         self.plotview.setFrameStyle(QFrame.StyledPanel)
 
-        self.plot = pg.PlotItem(enableMenu=False)
+        axes = {"bottom": AxisItem(orientation="bottom"),
+                "left": AxisItem(orientation="left")}
+        self.plot = pg.PlotItem(enableMenu=False, axisItems=axes)
+        self.plot.parameter_setter = ParameterSetter(self.plot)
+        self.plot.curve_items = []
+        self.plot.hull_items = []
+        self.plot.default_line_item = None
+        self.plot.display_convex_hull = self.display_convex_hull
         self.plot.setMouseEnabled(False, False)
         self.plot.hideButtons()
 
@@ -133,6 +245,8 @@ def __init__(self):
         self.plotview.setCentralItem(self.plot)
         self.mainArea.layout().addWidget(self.plotview)
 
+        VisualSettingsDialog(self, self.plot.parameter_setter.initial_settings)
+
     @Inputs.evaluation_results
     def set_results(self, results):
         self.closeContext()
@@ -146,6 +260,10 @@ def set_results(self, results):
 
     def clear(self):
         self.plot.clear()
+        self.plot.curve_items = []
+        self.plot.hull_items = []
+        self.plot.default_line_item = None
+        self.plot.display_convex_hull = self.display_convex_hull
         self.Warning.clear()
         self.Error.clear()
         self.results = None
@@ -173,6 +291,10 @@ def _initialize(self, results):
 
     def _replot(self):
         self.plot.clear()
+        self.plot.curve_items = []
+        self.plot.hull_items = []
+        self.plot.default_line_item = None
+        self.plot.display_convex_hull = self.display_convex_hull
         if self.results is not None:
             self._setup_plot()
 
@@ -205,28 +327,43 @@ def _plot_curve(self, target, clf_idx):
         if not points.is_valid:
             return False
 
+        param_setter = self.plot.parameter_setter
         color = self.colors[clf_idx]
-        pen = QPen(color, 1)
+        width = param_setter.line_settings[Updater.WIDTH_LABEL]
+        style = param_setter.line_settings[Updater.STYLE_LABEL]
+        pen = QPen(color, width, Updater.LINE_STYLES[style])
         pen.setCosmetic(True)
-        wide_pen = QPen(color, 3)
+        width = param_setter.wide_line_settings[Updater.WIDTH_LABEL]
+        style = param_setter.wide_line_settings[Updater.STYLE_LABEL]
+        wide_pen = QPen(color, width, Updater.LINE_STYLES[style])
         wide_pen.setCosmetic(True)
 
         def _plot(points, pen):
             contacted, respondents, _ = points
             if self.curve_type == CurveTypes.LiftCurve:
                 respondents = respondents / contacted
-            self.plot.plot(contacted, respondents, pen=pen, antialias=True)
+            curve = pg.PlotCurveItem(contacted, respondents, pen=pen,
+                                     antialias=True)
+            self.plot.addItem(curve)
+            return curve
 
-        _plot(points, wide_pen if not self.display_convex_hull else pen)
+        self.plot.curve_items.append(
+            _plot(points, wide_pen if not self.display_convex_hull else pen)
+        )
         if self.display_convex_hull:
-            _plot(hull, wide_pen)
+            self.plot.hull_items.append(_plot(hull, wide_pen))
         return True
 
     def _plot_default_line(self):
-        pen = QPen(QColor(20, 20, 20), 1, Qt.DashLine)
+        param_setter = self.plot.parameter_setter
+        width = param_setter.default_line_settings[Updater.WIDTH_LABEL]
+        style = param_setter.default_line_settings[Updater.STYLE_LABEL]
+        pen = QPen(QColor(20, 20, 20), width, Updater.LINE_STYLES[style])
         pen.setCosmetic(True)
         y0 = 1 if self.curve_type == CurveTypes.LiftCurve else 0
-        self.plot.plot([0, 1], [y0, 1], pen=pen, antialias=True)
+        curve = pg.PlotCurveItem([0, 1], [y0, 1], pen=pen, antialias=True)
+        self.plot.addItem(curve)
+        self.plot.default_line_item = curve
 
     def _set_undefined_curves_err_warn(self, is_valid):
         self.Error.undefined_curves.clear()
@@ -246,6 +383,10 @@ def send_report(self):
         self.report_plot()
         self.report_caption(caption)
 
+    def set_visual_settings(self, key, value):
+        self.plot.parameter_setter.set_parameter(key, value)
+        self.visual_settings[key] = value
+
 
 def points_from_results(results, target, clf_index):
     x, y, thresholds = cumulative_gains_from_results(results, target, clf_index)


Test patch:
diff --git a/Orange/widgets/evaluate/tests/test_owliftcurve.py b/Orange/widgets/evaluate/tests/test_owliftcurve.py
--- a/Orange/widgets/evaluate/tests/test_owliftcurve.py
+++ b/Orange/widgets/evaluate/tests/test_owliftcurve.py
@@ -4,6 +4,8 @@
 
 import numpy as np
 
+from AnyQt.QtGui import QFont, QPen
+
 from Orange.data import Table
 import Orange.evaluation
 import Orange.classification
@@ -57,6 +59,88 @@ def test_nan_input(self):
         self.send_signal(self.widget.Inputs.evaluation_results, None)
         self.assertFalse(self.widget.Error.invalid_results.is_shown())
 
+    def test_visual_settings(self):
+        graph = self.widget.plot
+
+        def test_settings():
+            font = QFont("Helvetica", italic=True, pointSize=20)
+            self.assertFontEqual(
+                graph.parameter_setter.title_item.item.font(), font
+            )
+
+            font.setPointSize(16)
+            for item in graph.parameter_setter.axis_items:
+                self.assertFontEqual(item.label.font(), font)
+
+            font.setPointSize(15)
+            for item in graph.parameter_setter.axis_items:
+                self.assertFontEqual(item.style["tickFont"], font)
+
+            self.assertEqual(
+                graph.parameter_setter.title_item.item.toPlainText(), "Foo"
+            )
+            self.assertEqual(graph.parameter_setter.title_item.text, "Foo")
+
+            for line in graph.curve_items:
+                pen: QPen = line.opts["pen"]
+                self.assertEqual(pen.width(), 3)
+
+            for line in graph.hull_items:
+                pen: QPen = line.opts["pen"]
+                self.assertEqual(pen.width(), 10)
+
+            pen: QPen = graph.default_line_item.opts["pen"]
+            self.assertEqual(pen.width(), 4)
+
+        test_on_test = Orange.evaluation.TestOnTestData(store_data=True)
+        res = test_on_test(
+            data=self.lenses[::2], test_data=self.lenses[1::2],
+            learners=[Orange.classification.MajorityLearner(),
+                      Orange.classification.KNNLearner()]
+        )
+        self.send_signal(self.widget.Inputs.evaluation_results, res)
+        key, value = ("Fonts", "Font family", "Font family"), "Helvetica"
+        self.widget.set_visual_settings(key, value)
+
+        key, value = ("Fonts", "Title", "Font size"), 20
+        self.widget.set_visual_settings(key, value)
+        key, value = ("Fonts", "Title", "Italic"), True
+        self.widget.set_visual_settings(key, value)
+
+        key, value = ("Fonts", "Axis title", "Font size"), 16
+        self.widget.set_visual_settings(key, value)
+        key, value = ("Fonts", "Axis title", "Italic"), True
+        self.widget.set_visual_settings(key, value)
+
+        key, value = ("Fonts", "Axis ticks", "Font size"), 15
+        self.widget.set_visual_settings(key, value)
+        key, value = ("Fonts", "Axis ticks", "Italic"), True
+        self.widget.set_visual_settings(key, value)
+
+        key, value = ("Annotations", "Title", "Title"), "Foo"
+        self.widget.set_visual_settings(key, value)
+
+        key, value = ("Figure", "Wide line", "Width"), 10
+        self.widget.set_visual_settings(key, value)
+
+        key, value = ("Figure", "Thin Line", "Width"), 3
+        self.widget.set_visual_settings(key, value)
+
+        key, value = ("Figure", "Default Line", "Width"), 4
+        self.widget.set_visual_settings(key, value)
+
+        self.send_signal(self.widget.Inputs.evaluation_results, res)
+        test_settings()
+
+        self.send_signal(self.widget.Inputs.evaluation_results, None)
+        self.send_signal(self.widget.Inputs.evaluation_results, res)
+        test_settings()
+
+    def assertFontEqual(self, font1, font2):
+        self.assertEqual(font1.family(), font2.family())
+        self.assertEqual(font1.pointSize(), font2.pointSize())
+        self.assertEqual(font1.italic(), font2.italic())
+
 
 class UtilsTest(unittest.TestCase):
     @staticmethod