Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC][ENH] OWScatterPlot: axis displays time specific labels for time variable #4434

Merged
merged 1 commit into from
Feb 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Orange/widgets/visualize/owscatterplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
77 changes: 76 additions & 1 deletion Orange/widgets/visualize/owscatterplotgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion Orange/widgets/visualize/tests/test_owscatterplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down