Repository: biolab/orange3

Issue:
Heat map center value feature request
**Is your feature request related to a problem? Please describe.**

When generating Heat Maps, I would like to be able to display the data relative to some central value. For instance, if 1 is a "no effect" value, I'd like to see values >1 light up as red, <1 as green, for instance. 

**Describe the solution you'd like**

I'm using the latest Orange available in Anaconda, v3.25.

Scaling in the heat map widget seems to be a moving target. You used to be able to center the colors around 0. That was sometimes useful when you had data that was actually centered around 0. Now that's gone. It would be nice to be able to center around an arbitrary value. For instance, I have some data that's centered around 0, others that is centered around 1.

**Describe alternatives you've considered**
My only alternative now is to use the sliders that control the High and Low values manually. However, that has to be changed as the data changes.

**Additional context**
Add any other context or screenshots about the feature request here.



Discussion:
For 0-centering you can use the Diverging pallets which are centered at 0. A work around for visually centering the palette at a specific value might be the use of Feature Constructor. A new feature could be generated so that it has 0 at the desired center of the original feature. However, this would be impractical for large number of features and the legend would be shifted due to the processing of the original feature.
Thanks for the suggestion, but I'm in the situation you mentioned - lots of features. Also, that workaround is not useful when you want to examine the original values in the heatmap.
@leeherman99, we discussed this at today's meeting. This is one of "would be nice to have" features. We'll see whether it is easy to implement and incorporate into user interface. If it is (which will be the case, I'm guessing), we'll add it. If it would overly complicate the code or the interface, we'll decide it is not nice enough to have. :)
Janez

I would remind you that you already have the code to do this, since the
capability to center on zero existed for a long time on this widget, until
the very latest version. All you would have to do is parameterize the
center point.

Lee

On Fri, May 22, 2020 at 5:27 AM Janez Demšar <notifications@github.com>
wrote:

> @leeherman99 <https://github.com/leeherman99>, we discussed this at
> today's meeting. This is one of "would be nice to have" features. We'll see
> whether it is easy to implement and incorporate into user interface. If it
> is (which will be the case, I'm guessing), we'll add it. If it would overly
> complicate the code or the interface, we'll decide it is not nice enough to
> have. :)
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/biolab/orange3/issues/4789#issuecomment-632593993>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AKJ5W2JECGS7CSNHUWBL6IDRSZAOTANCNFSM4NEFOLJQ>
> .
>

Heatmap still centers to zero if you choose one of diverging palettes (at least it should). The code is still there, we just eliminated the checkbox because it doesn't make sense to use a diverging palette unless you want to center, and it doesn't make sense to center without a diverging palette.
Yes, but as I mentioned earlier, sometimes you want to center on a value other than zero (one, for example).
Yes, sure, I just wanted to answer that the code for centering is still there. :) We'll add parametrization if it doesn't complicate things. Sometimes a simple request can turn out to be ugly to implement.
Yes, I understand that. Somehow I doubt that this the case here.

Another advantage of tuneable centers is that it gives you the flexibility
to make just about any color scheme approximate a diverging palette.

On Fri, May 22, 2020 at 8:44 AM Janez Demšar <notifications@github.com>
wrote:

> Yes, sure, I just wanted to answer that the code for centering is still
> there. :) We'll add parametrization if it doesn't complicate things.
> Sometimes a simple request can turn out to be ugly to implement.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/biolab/orange3/issues/4789#issuecomment-632672050>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AKJ5W2JOFZBJUBHEKS2S6QTRSZXRJANCNFSM4NEFOLJQ>
> .
>


Resolving patch:
diff --git a/Orange/widgets/utils/colorgradientselection.py b/Orange/widgets/utils/colorgradientselection.py
--- a/Orange/widgets/utils/colorgradientselection.py
+++ b/Orange/widgets/utils/colorgradientselection.py
@@ -2,11 +2,12 @@
 
 from AnyQt.QtCore import Qt, QSize, QAbstractItemModel, Property
 from AnyQt.QtWidgets import (
-    QWidget, QSlider, QFormLayout, QComboBox, QStyle
-)
+    QWidget, QSlider, QFormLayout, QComboBox, QStyle,
+    QHBoxLayout, QLineEdit, QLabel)
 from AnyQt.QtCore import Signal
+from AnyQt.QtGui import QFontMetrics, QDoubleValidator
 
-from Orange.widgets.utils import itemmodels
+from Orange.widgets.utils import itemmodels, colorpalettes
 
 
 class ColorGradientSelection(QWidget):
@@ -14,14 +15,16 @@ class ColorGradientSelection(QWidget):
 
     currentIndexChanged = Signal(int)
     thresholdsChanged = Signal(float, float)
+    centerChanged = Signal(float)
 
-    def __init__(self, *args, thresholds=(0.0, 1.0), **kwargs):
+    def __init__(self, *args, thresholds=(0.0, 1.0), center=None, **kwargs):
         super().__init__(*args, **kwargs)
 
         low = round(clip(thresholds[0], 0., 1.), 2)
         high = round(clip(thresholds[1], 0., 1.), 2)
         high = max(low, high)
         self.__threshold_low, self.__threshold_high = low, high
+        self.__center = center
         form = QFormLayout(
             formAlignment=Qt.AlignLeft,
             labelAlignment=Qt.AlignLeft,
@@ -43,6 +46,29 @@ def __init__(self, *args, thresholds=(0.0, 1.0), **kwargs):
         self.gradient_cb.activated[int].connect(self.activated)
         self.gradient_cb.currentIndexChanged.connect(self.currentIndexChanged)
 
+        if center is not None:
+            def __on_center_changed():
+                self.__center = float(self.center_edit.text() or "0")
+                self.centerChanged.emit(self.__center)
+
+            self.center_box = QWidget()
+            center_layout = QHBoxLayout()
+            self.center_box.setLayout(center_layout)
+            width = QFontMetrics(self.font()).boundingRect("9999999").width()
+            self.center_edit = QLineEdit(
+                text=f"{self.__center}",
+                maximumWidth=width, placeholderText="0", alignment=Qt.AlignRight)
+            self.center_edit.setValidator(QDoubleValidator())
+            self.center_edit.editingFinished.connect(__on_center_changed)
+            center_layout.setContentsMargins(0, 0, 0, 0)
+            center_layout.addStretch(1)
+            center_layout.addWidget(QLabel("Centered at"))
+            center_layout.addWidget(self.center_edit)
+            self.gradient_cb.currentIndexChanged.connect(
+                self.__update_center_visibility)
+        else:
+            self.center_box = None
+
         slider_low = QSlider(
             objectName="threshold-low-slider", minimum=0, maximum=100,
             value=int(low * 100), orientation=Qt.Horizontal,
@@ -60,6 +86,8 @@ def __init__(self, *args, thresholds=(0.0, 1.0), **kwargs):
                               "gradient from the higher end")
         )
         form.setWidget(0, QFormLayout.SpanningRole, self.gradient_cb)
+        if self.center_box:
+            form.setWidget(1, QFormLayout.SpanningRole, self.center_box)
         form.addRow(self.tr("Low:"), slider_low)
         form.addRow(self.tr("High:"), slider_high)
         self.slider_low = slider_low
@@ -79,6 +107,7 @@ def findData(self, data: Any, role: Qt.ItemDataRole) -> int:
 
     def setCurrentIndex(self, index: int) -> None:
         self.gradient_cb.setCurrentIndex(index)
+        self.__update_center_visibility()
 
     def currentIndex(self) -> int:
         return self.gradient_cb.currentIndex()
@@ -109,6 +138,14 @@ def thresholdHigh(self) -> float:
     def setThresholdHigh(self, high: float) -> None:
         self.setThresholds(min(self.__threshold_low, high), high)
 
+    def center(self) -> float:
+        return self.__center
+
+    def setCenter(self, center: float) -> None:
+        self.__center = center
+        self.center_edit.setText(f"{center}")
+        self.centerChanged.emit(center)
+
     thresholdHigh_ = Property(
         float, thresholdLow, setThresholdLow, notify=thresholdsChanged)
 
@@ -146,6 +183,15 @@ def setThresholds(self, low: float, high: float) -> None:
             self.slider_high.setSliderPosition(high * 100)
             self.thresholdsChanged.emit(high, low)
 
+    def __update_center_visibility(self):
+        if self.center_box is None:
+            return
+
+        palette = self.currentData()
+        self.center_box.setVisible(
+            isinstance(palette, colorpalettes.Palette)
+            and palette.flags & palette.Flags.Diverging != 0)
+
 
 def clip(a, amin, amax):
     return min(max(a, amin), amax)
diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py
--- a/Orange/widgets/visualize/owheatmap.py
+++ b/Orange/widgets/visualize/owheatmap.py
@@ -155,6 +155,7 @@ class Outputs:
 
     threshold_low = settings.Setting(0.0)
     threshold_high = settings.Setting(1.0)
+    color_center = settings.Setting(0)
 
     merge_kmeans = settings.Setting(False)
     merge_kmeans_k = settings.Setting(50)
@@ -202,6 +203,12 @@ class Error(widget.OWWidget.Error):
     class Warning(widget.OWWidget.Warning):
         empty_clusters = Msg("Empty clusters were removed")
 
+    UserAdviceMessages = [
+        widget.Message(
+            "For data with a meaningful mid-point, "
+            "choose one of diverging palettes.",
+            "diverging_palette")]
+
     def __init__(self):
         super().__init__()
         self.__pending_selection = self.selected_rows
@@ -244,6 +251,7 @@ def _():
 
         self.color_map_widget = cmw = ColorGradientSelection(
             thresholds=(self.threshold_low, self.threshold_high),
+            center=self.color_center
         )
         model = itemmodels.ContinuousPalettesModel(parent=self)
         cmw.setModel(model)
@@ -257,6 +265,12 @@ def _set_thresholds(low, high):
             self.threshold_low, self.threshold_high = low, high
             self.update_color_schema()
         cmw.thresholdsChanged.connect(_set_thresholds)
+
+        def _set_centering(center):
+            self.color_center = center
+            self.update_color_schema()
+        cmw.centerChanged.connect(_set_centering)
+
         colorbox.layout().addWidget(self.color_map_widget)
 
         mergebox = gui.vBox(self.controlArea, "Merge",)
@@ -449,7 +463,7 @@ def color_palette(self):
     def color_map(self) -> GradientColorMap:
         return GradientColorMap(
             self.color_palette(), (self.threshold_low, self.threshold_high),
-            0 if self.center_palette else None
+            self.color_map_widget.center() if self.center_palette else None
         )
 
     def clear(self):


Test patch:
diff --git a/Orange/widgets/utils/tests/test_colorgradientselection.py b/Orange/widgets/utils/tests/test_colorgradientselection.py
--- a/Orange/widgets/utils/tests/test_colorgradientselection.py
+++ b/Orange/widgets/utils/tests/test_colorgradientselection.py
@@ -1,11 +1,15 @@
+from unittest.mock import Mock
+
 import numpy as np
 
 from AnyQt.QtTest import QSignalSpy
-from AnyQt.QtCore import Qt, QStringListModel
+from AnyQt.QtCore import Qt, QStringListModel, QModelIndex
 
+from Orange.widgets.utils import itemmodels
 from Orange.widgets.utils.colorgradientselection import ColorGradientSelection
 from Orange.widgets.tests.base import GuiTest
 
+
 class TestColorGradientSelection(GuiTest):
     def test_constructor(self):
         w = ColorGradientSelection(thresholds=(0.1, 0.9))
@@ -68,3 +72,40 @@ def test_slider_move(self):
         low, high = changed[-1]
         self.assertLessEqual(low, high)
         self.assertEqual(high, 0.0)
+
+    def test_center(self):
+        w = ColorGradientSelection(center=42)
+        self.assertEqual(w.center(), 42)
+        w.setCenter(40)
+        self.assertEqual(w.center(), 40)
+
+    def test_center_visibility(self):
+        w = ColorGradientSelection(center=0)
+        w.center_box.setVisible = Mock()
+        model = itemmodels.ContinuousPalettesModel()
+        w.setModel(model)
+        for row in range(model.rowCount(QModelIndex())):
+            palette = model.data(model.index(row, 0), Qt.UserRole)
+            if palette:
+                if palette.flags & palette.Diverging:
+                    diverging = row
+                else:
+                    nondiverging = row
+
+        w.setCurrentIndex(diverging)
+        w.center_box.setVisible.assert_called_with(True)
+        w.setCurrentIndex(nondiverging)
+        w.center_box.setVisible.assert_called_with(False)
+        w.setCurrentIndex(diverging)
+        w.center_box.setVisible.assert_called_with(True)
+
+        w = ColorGradientSelection()
+        self.assertIsNone(w.center_box)
+
+    def test_center_changed(self):
+        w = ColorGradientSelection(center=42)
+        changed = QSignalSpy(w.centerChanged)
+        w.center_edit.setText("41")
+        w.center_edit.editingFinished.emit()
+        self.assertEqual(w.center(), 41)
+        self.assertEqual(list(changed), [[41]])
diff --git a/Orange/widgets/visualize/tests/test_owheatmap.py b/Orange/widgets/visualize/tests/test_owheatmap.py
--- a/Orange/widgets/visualize/tests/test_owheatmap.py
+++ b/Orange/widgets/visualize/tests/test_owheatmap.py
@@ -2,7 +2,7 @@
 # pylint: disable=missing-docstring, protected-access
 import warnings
 import unittest
-from unittest.mock import patch
+from unittest.mock import patch, Mock
 
 import numpy as np
 from sklearn.exceptions import ConvergenceWarning
@@ -245,6 +245,21 @@ def test_palette_centering(self):
                 colors = image_row_colors(image)
                 np.testing.assert_almost_equal(colors, desired)
 
+    def test_centering_threshold_change(self):
+        data = np.arange(2).reshape(-1, 1)
+        table = Table.from_numpy(Domain([ContinuousVariable("y")]), data)
+        self.send_signal(self.widget.Inputs.data, table)
+
+        cmw = self.widget.color_map_widget
+        palette_index = cmw.findData(
+            colorpalettes.ContinuousPalettes["diverging_bwr_40_95_c42"],
+            Qt.UserRole)
+        cmw.setCurrentIndex(palette_index)
+
+        self.widget.update_color_schema = Mock()
+        cmw.centerChanged.emit(42)
+        self.widget.update_color_schema.assert_called()
+
     def test_palette_center(self):
         widget = self.widget
         model = widget.color_map_widget.model()