Repository: biolab/orange3

Issue:
Scatter plot shows floats instead of dates on axes
How to get dates as date form in scatter plot? They are in date form in datatable but in numeric form in scatter plot.
![image](https://user-images.githubusercontent.com/60651045/73739062-3c206a80-474e-11ea-9c53-84c07896e8d4.png)



Discussion:
Thanks for reporting this. It's ugly enough to be classified as bug.

To whomever who will work on this: I suppose the nicest solution would be to use `Orange.preprocess.discretize.time_binnings` to get an appropriate number of sensible labels.

Resolving patch:
diff --git a/Orange/widgets/visualize/owscatterplot.py b/Orange/widgets/visualize/owscatterplot.py
--- a/Orange/widgets/visualize/owscatterplot.py
+++ b/Orange/widgets/visualize/owscatterplot.py
@@ -119,6 +119,8 @@ def update_colors(self):
 
     def update_axes(self):
         for axis, title in self.master.get_axes().items():
+            use_time = title is not None and title.is_time
+            self.plot_widget.plotItem.getAxis(axis).use_time(use_time)
             self.plot_widget.setLabel(axis=axis, text=title or "")
             if title is None:
                 self.plot_widget.hideAxis(axis)
diff --git a/Orange/widgets/visualize/owscatterplotgraph.py b/Orange/widgets/visualize/owscatterplotgraph.py
--- a/Orange/widgets/visualize/owscatterplotgraph.py
+++ b/Orange/widgets/visualize/owscatterplotgraph.py
@@ -3,6 +3,8 @@
 import warnings
 from xml.sax.saxutils import escape
 from math import log10, floor, ceil
+from datetime import datetime, timezone
+from time import gmtime
 
 import numpy as np
 from AnyQt.QtCore import Qt, QRectF, QSize, QTimer, pyqtSignal as Signal, \
@@ -22,6 +24,7 @@
 )
 from pyqtgraph.graphicsItems.TextItem import TextItem
 
+from Orange.preprocess.discretize import _time_binnings
 from Orange.widgets.utils import colorpalettes
 from Orange.util import OrangeDeprecationWarning
 from Orange.widgets import gui
@@ -286,6 +289,77 @@ def _make_pen(color, width):
     return p
 
 
+class AxisItem(pg.AxisItem):
+    """
+    Axis that if needed displays ticks appropriate for time data.
+    """
+
+    _label_width = 80
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._use_time = False
+
+    def use_time(self, enable):
+        """Enables axes to display ticks for time data."""
+        self._use_time = enable
+        self.enableAutoSIPrefix(not enable)
+
+    def tickValues(self, minVal, maxVal, size):
+        """Find appropriate tick locations."""
+        if not self._use_time:
+            return super().tickValues(minVal, maxVal, size)
+
+        # if timezone is not set, then local is used which cause exceptions
+        minVal = max(minVal,
+                     datetime.min.replace(tzinfo=timezone.utc).timestamp() + 1)
+        maxVal = min(maxVal,
+                     datetime.max.replace(tzinfo=timezone.utc).timestamp() - 1)
+        mn, mx = gmtime(minVal), gmtime(maxVal)
+
+        try:
+            bins = _time_binnings(mn, mx, 6, 30)[-1]
+        except (IndexError, ValueError):
+            # cannot handle very large and very small time intervals
+            return super().tickValues(minVal, maxVal, size)
+
+        ticks = bins.thresholds
+
+        max_steps = max(int(size / self._label_width), 1)
+        if len(ticks) > max_steps:
+            # remove some of ticks so that they don't overlap
+            step = int(np.ceil(float(len(ticks)) / max_steps))
+            ticks = ticks[::step]
+
+        spacing = min(b - a for a, b in zip(ticks[:-1], ticks[1:]))
+        return [(spacing, ticks)]
+
+    def tickStrings(self, values, scale, spacing):
+        """Format tick values according to space between them."""
+        if not self._use_time:
+            return super().tickStrings(values, scale, spacing)
+
+        if spacing >= 3600 * 24 * 365:
+            fmt = "%Y"
+        elif spacing >= 3600 * 24 * 28:
+            fmt = "%Y %b"
+        elif spacing >= 3600 * 24:
+            fmt = "%Y %b %d"
+        elif spacing >= 3600:
+            fmt = "%d %Hh"
+        elif spacing >= 60:
+            fmt = "%H:%M"
+        elif spacing >= 1:
+            fmt = "%H:%M:%S"
+        else:
+            fmt = '%S.%f'
+
+        # if timezone is not set, then local timezone is used
+        # which cause exceptions for edge cases
+        return [datetime.fromtimestamp(x, tz=timezone.utc).strftime(fmt)
+                for x in values]
+
+
 class OWScatterPlotBase(gui.OWComponent, QObject):
     """
     Provide a graph component for widgets that show any kind of point plot
@@ -408,8 +482,9 @@ def __init__(self, scatter_widget, parent=None, view_box=ViewBox):
         self.subset_is_shown = False
 
         self.view_box = view_box(self)
+        _axis = {"left": AxisItem("left"), "bottom": AxisItem("bottom")}
         self.plot_widget = pg.PlotWidget(viewBox=self.view_box, parent=parent,
-                                         background="w")
+                                         background="w", axisItems=_axis)
         self.plot_widget.hideAxis("left")
         self.plot_widget.hideAxis("bottom")
         self.plot_widget.getPlotItem().buttonsHidden = True


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_owscatterplot.py b/Orange/widgets/visualize/tests/test_owscatterplot.py
--- a/Orange/widgets/visualize/tests/test_owscatterplot.py
+++ b/Orange/widgets/visualize/tests/test_owscatterplot.py
@@ -8,7 +8,9 @@
 from AnyQt.QtWidgets import QToolTip
 from AnyQt.QtGui import QColor
 
-from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable
+from Orange.data import (
+    Table, Domain, ContinuousVariable, DiscreteVariable, TimeVariable
+)
 from Orange.widgets.tests.base import (
     WidgetTest, WidgetOutputsTestMixin, datasets, ProjectionWidgetTestMixin
 )
@@ -1074,6 +1076,31 @@ def test_update_regression_line_is_called(self):
         urline.assert_called_once()
         urline.reset_mock()
 
+    def test_time_axis(self):
+        a = np.array([[1581953776, 1], [1581963776, 2], [1582953776, 3]])
+        d1 = Domain([ContinuousVariable("time"), ContinuousVariable("value")])
+        data = Table.from_numpy(d1, a)
+        d2 = Domain([TimeVariable("time"), ContinuousVariable("value")])
+        data_time = Table.from_numpy(d2, a)
+
+        x_axis = self.widget.graph.plot_widget.plotItem.getAxis("bottom")
+
+        self.send_signal(self.widget.Inputs.data, data)
+        self.assertFalse(x_axis._use_time)
+        _ticks = x_axis.tickValues(1581953776, 1582953776, 1000)
+        ticks = x_axis.tickStrings(_ticks[0][1], 1, _ticks[0][0])
+        try:
+            float(ticks[0])
+        except ValueError:
+            self.fail("axis should display floats")
+
+        self.send_signal(self.widget.Inputs.data, data_time)
+        self.assertTrue(x_axis._use_time)
+        _ticks = x_axis.tickValues(1581953776, 1582953776, 1000)
+        ticks = x_axis.tickStrings(_ticks[0][1], 1, _ticks[0][0])
+        with self.assertRaises(ValueError):
+            float(ticks[0])
+
 
 if __name__ == "__main__":
     import unittest