Skip to content

Commit 4270d0f

Browse files
committed
owscatterplot: add time axis
1 parent 9a4da3d commit 4270d0f

File tree

3 files changed

+106
-2
lines changed

3 files changed

+106
-2
lines changed

Orange/widgets/visualize/owscatterplot.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def update_colors(self):
119119

120120
def update_axes(self):
121121
for axis, title in self.master.get_axes().items():
122+
use_time = title is not None and title.is_time
123+
self.plot_widget.plotItem.getAxis(axis).use_time(use_time)
122124
self.plot_widget.setLabel(axis=axis, text=title or "")
123125
if title is None:
124126
self.plot_widget.hideAxis(axis)

Orange/widgets/visualize/owscatterplotgraph.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import warnings
44
from xml.sax.saxutils import escape
55
from math import log10, floor, ceil
6+
from datetime import datetime, timezone
7+
from time import gmtime
68

79
import numpy as np
810
from AnyQt.QtCore import Qt, QRectF, QSize, QTimer, pyqtSignal as Signal, \
@@ -22,6 +24,7 @@
2224
)
2325
from pyqtgraph.graphicsItems.TextItem import TextItem
2426

27+
from Orange.preprocess.discretize import _time_binnings
2528
from Orange.widgets.utils import colorpalettes
2629
from Orange.util import OrangeDeprecationWarning
2730
from Orange.widgets import gui
@@ -286,6 +289,77 @@ def _make_pen(color, width):
286289
return p
287290

288291

292+
class AxisItem(pg.AxisItem):
293+
"""
294+
Axis that if needed displays ticks appropriate for time data.
295+
"""
296+
297+
_label_width = 80
298+
299+
def __init__(self, *args, **kwargs):
300+
super().__init__(*args, **kwargs)
301+
self._use_time = False
302+
303+
def use_time(self, enable):
304+
"""Enables axes to display ticks for time data."""
305+
self._use_time = enable
306+
self.enableAutoSIPrefix(not enable)
307+
308+
def tickValues(self, minVal, maxVal, size):
309+
"""Find appropriate tick locations."""
310+
if not self._use_time:
311+
return super().tickValues(minVal, maxVal, size)
312+
313+
# if timezone is not set, then local is used which cause exceptions
314+
minVal = max(minVal,
315+
datetime.min.replace(tzinfo=timezone.utc).timestamp() + 1)
316+
maxVal = min(maxVal,
317+
datetime.max.replace(tzinfo=timezone.utc).timestamp() - 1)
318+
mn, mx = gmtime(minVal), gmtime(maxVal)
319+
320+
try:
321+
bins = _time_binnings(mn, mx, 6, 30)[-1]
322+
except (IndexError, ValueError):
323+
# cannot handle very large and very small time intervals
324+
return super().tickValues(minVal, maxVal, size)
325+
326+
ticks = bins.thresholds
327+
328+
max_steps = max(int(size / self._label_width), 1)
329+
if len(ticks) > max_steps:
330+
# remove some of ticks so that they don't overlap
331+
step = int(np.ceil(float(len(ticks)) / max_steps))
332+
ticks = ticks[::step]
333+
334+
spacing = min(b - a for a, b in zip(ticks[:-1], ticks[1:]))
335+
return [(spacing, ticks)]
336+
337+
def tickStrings(self, values, scale, spacing):
338+
"""Format tick values according to space between them."""
339+
if not self._use_time:
340+
return super().tickStrings(values, scale, spacing)
341+
342+
if spacing >= 3600 * 24 * 365:
343+
fmt = "%Y"
344+
elif spacing >= 3600 * 24 * 28:
345+
fmt = "%Y %b"
346+
elif spacing >= 3600 * 24:
347+
fmt = "%Y %b %d"
348+
elif spacing >= 3600:
349+
fmt = "%d %Hh"
350+
elif spacing >= 60:
351+
fmt = "%H:%M"
352+
elif spacing >= 1:
353+
fmt = "%H:%M:%S"
354+
else:
355+
fmt = '%S.%f'
356+
357+
# if timezone is not set, then local timezone is used
358+
# which cause exceptions for edge cases
359+
return [datetime.fromtimestamp(x, tz=timezone.utc).strftime(fmt)
360+
for x in values]
361+
362+
289363
class OWScatterPlotBase(gui.OWComponent, QObject):
290364
"""
291365
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):
408482
self.subset_is_shown = False
409483

410484
self.view_box = view_box(self)
485+
_axis = {"left": AxisItem("left"), "bottom": AxisItem("bottom")}
411486
self.plot_widget = pg.PlotWidget(viewBox=self.view_box, parent=parent,
412-
background="w")
487+
background="w", axisItems=_axis)
413488
self.plot_widget.hideAxis("left")
414489
self.plot_widget.hideAxis("bottom")
415490
self.plot_widget.getPlotItem().buttonsHidden = True

Orange/widgets/visualize/tests/test_owscatterplot.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from AnyQt.QtWidgets import QToolTip
99
from AnyQt.QtGui import QColor
1010

11-
from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable
11+
from Orange.data import (
12+
Table, Domain, ContinuousVariable, DiscreteVariable, TimeVariable
13+
)
1214
from Orange.widgets.tests.base import (
1315
WidgetTest, WidgetOutputsTestMixin, datasets, ProjectionWidgetTestMixin
1416
)
@@ -1074,6 +1076,31 @@ def test_update_regression_line_is_called(self):
10741076
urline.assert_called_once()
10751077
urline.reset_mock()
10761078

1079+
def test_time_axis(self):
1080+
a = np.array([[1581953776, 1], [1581963776, 2], [1582953776, 3]])
1081+
d1 = Domain([ContinuousVariable("time"), ContinuousVariable("value")])
1082+
data = Table.from_numpy(d1, a)
1083+
d2 = Domain([TimeVariable("time"), ContinuousVariable("value")])
1084+
data_time = Table.from_numpy(d2, a)
1085+
1086+
x_axis = self.widget.graph.plot_widget.plotItem.getAxis("bottom")
1087+
1088+
self.send_signal(self.widget.Inputs.data, data)
1089+
self.assertFalse(x_axis._use_time)
1090+
_ticks = x_axis.tickValues(1581953776, 1582953776, 1000)
1091+
ticks = x_axis.tickStrings(_ticks[0][1], 1, _ticks[0][0])
1092+
try:
1093+
float(ticks[0])
1094+
except ValueError:
1095+
self.fail("axis should display floats")
1096+
1097+
self.send_signal(self.widget.Inputs.data, data_time)
1098+
self.assertTrue(x_axis._use_time)
1099+
_ticks = x_axis.tickValues(1581953776, 1582953776, 1000)
1100+
ticks = x_axis.tickStrings(_ticks[0][1], 1, _ticks[0][0])
1101+
with self.assertRaises(ValueError):
1102+
float(ticks[0])
1103+
10771104

10781105
if __name__ == "__main__":
10791106
import unittest

0 commit comments

Comments
 (0)