Skip to content

Commit 1656c84

Browse files
committed
ScatterplotGraph aggregation: Fix tooltips
1 parent a00c5cf commit 1656c84

File tree

6 files changed

+179
-38
lines changed

6 files changed

+179
-38
lines changed

Orange/widgets/utils/plot/owplotgui.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ def aggregate_points_check_box(self, widget):
646646
cb.setToolTip(
647647
"Dense regions with many points are aggregated into "
648648
"circles or pie charts,\n"
649-
"unless data is jittered or labels, selection or subset is shown")
649+
"unless data is jittered or labels, selection or subset is shown.")
650650

651651
def regression_line_check_box(self, widget):
652652
self._master.cb_reg_line = \

Orange/widgets/visualize/owscatterplotgraph.py

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import itertools
33
import warnings
44
from collections import Counter
5-
from typing import Callable
5+
from typing import Callable, NamedTuple
66
from xml.sax.saxutils import escape
77
from datetime import datetime, timezone
88

@@ -163,6 +163,11 @@ def __init__(self, *args, **kwargs):
163163
self._agg_size_default = 50
164164
self._agg_threshold_default = 10
165165

166+
self._nonaggregated = True
167+
self._agg_indices = None
168+
self._agg_coords = None
169+
self._agg_r2s = None
170+
166171
def setZ(self, z):
167172
"""
168173
Set z values for all points.
@@ -223,9 +228,14 @@ def setAggregation(self, enabled):
223228
def paint(self, painter, option, widget=None):
224229
# super().paint will reset painter.transform, so we must compute
225230
# all transformations in advance
226-
agg_pts, agg_colors = self._get_aggregated_points(painter)
231+
self._nonaggregated = True
232+
self._agg_indices = None
233+
self._agg_coords = None
234+
self._agg_r2s = None
235+
agg_colors = self._get_aggregated_points(painter)
227236

228237
try:
238+
self.data["visible"] = self._nonaggregated
229239
if self._z_mapping is not None:
230240
assert len(self._z_mapping) == len(self.data)
231241
self.data = self.data[self._z_mapping]
@@ -235,19 +245,23 @@ def paint(self, painter, option, widget=None):
235245
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
236246
super().paint(painter, option, widget)
237247
finally:
248+
self.data["visible"] = True
238249
if self._inv_mapping is not None:
239250
self.data = self.data[self._inv_mapping]
240251

241-
self._paint_aggregated_points(painter, agg_pts, agg_colors)
252+
self._paint_aggregated_points(painter, agg_colors)
253+
254+
def _grid_index(self, x, y):
255+
assert self._aggregation_grid_pars is not None
242256

243257
def _get_aggregated_points(self, painter):
244258
if self._aggregation_size is None:
245-
return None, None
259+
return None
246260

247261
viewmask = self._maskAt(self.viewRect())
248262
data = self.data[viewmask]
249263
if len(data) == 0:
250-
return None, None
264+
return None
251265

252266
x, y = data["x"], data["y"]
253267
xmi, xma = np.min(x), np.max(x)
@@ -269,43 +283,43 @@ def _get_aggregated_points(self, painter):
269283
counts = np.bincount(idx, minlength=NX * NY)
270284

271285
if np.max(counts) < self._aggregation_threshold:
272-
return None, None
286+
return None
273287

274-
visible = np.ones(len(self.data), dtype=bool)
275-
visible[viewmask] = counts[idx] < self._aggregation_threshold
276-
self.data["visible"] = visible
288+
self._nonaggregated = np.ones(len(self.data), dtype=bool)
289+
self._nonaggregated[viewmask] = counts[idx] < self._aggregation_threshold
277290

278291
agg_pts = []
292+
self._agg_indices = []
279293
agg_colors = []
280294
brushes = data["brush"]
281295
for i, count in enumerate(counts):
282296
if count < self._aggregation_threshold:
283297
continue
284298
mask = idx == i
285299
agg_pts.append([np.mean(x[mask]), np.mean(y[mask])])
300+
self._agg_indices.append(np.flatnonzero(mask))
286301
agg_colors.append(
287302
Counter(
288303
brush.color().getRgb() for brush in brushes[mask]
289304
)
290305
)
291-
agg_pts = fn.transformCoordinates(painter.transform(), np.array(agg_pts).T).T
292-
293-
return agg_pts, agg_colors
306+
self._agg_coords = fn.transformCoordinates(painter.transform(), np.array(agg_pts).T).T
307+
return agg_colors
294308

295-
def _paint_aggregated_points(self, painter, agg_pts, agg_colors):
296-
if agg_pts is None:
309+
def _paint_aggregated_points(self, painter, agg_colors):
310+
if self._agg_coords is None:
297311
return
298312

299-
self.data["visible"] = True
300-
301313
countf = 8 / max(sum(c.values()) for c in agg_colors)
302314
text_opt = QTextOption()
303315
text_opt.setAlignment(Qt.AlignCenter)
304-
for (pt, spot_colors) in zip(agg_pts, agg_colors):
316+
rs = []
317+
for (pt, spot_colors) in zip(self._agg_coords, agg_colors):
305318
painter.resetTransform()
306319
painter.translate(*pt)
307320
total = sum(spot_colors.values())
308321
r = int(6 + countf * total)
322+
rs.append(r + 12)
309323
if len(spot_colors) == 1:
310324
color = QColor(*next(iter(spot_colors)))
311325
painter.setBrush(fn.mkBrush(color))
@@ -332,6 +346,33 @@ def _paint_aggregated_points(self, painter, agg_pts, agg_colors):
332346
painter.setBrush(fn.mkBrush(QColor(0, 0, 0)))
333347
painter.setPen(fn.mkPen(QColor(0, 0, 0)))
334348
painter.drawText(QRectF(-15, -15, 30, 30), str(total), text_opt)
349+
self._agg_r2s = np.array(rs) ** 2
350+
351+
def aggregatedPointsAt(self, pos):
352+
"""
353+
Returns indices of aggregated points for the given **scene** coordinate.
354+
355+
Note that unlike pointsAt, this function's argument is scene coordinate
356+
and not data coordinate. This is because the aggregation is done in scene
357+
coordinates.
358+
"""
359+
if self._agg_indices is None:
360+
indices = []
361+
else:
362+
x, y = pos.x(), pos.y()
363+
aggs = np.flatnonzero(
364+
np.sum((self._agg_coords - np.array([x, y])) ** 2, axis=1)
365+
< self._agg_r2s)
366+
indices = list(itertools.chain(*(self._agg_indices[i] for i in aggs)))
367+
return self.points()[indices][::-1]
368+
369+
def pointsAt(self, pos):
370+
# Override to ignore aggregated points.
371+
try:
372+
self.data["visible"] = self._nonaggregated
373+
return super().pointsAt(pos)
374+
finally:
375+
self.data["visible"] = True
335376

336377
def _define_symbols():
337378
"""
@@ -628,7 +669,11 @@ def get_size_data(self):
628669
show_legend = Setting(True)
629670
class_density = Setting(False)
630671
jitter_size = Setting(0)
631-
aggregate_dense_regions = False # Override in subclasses
672+
673+
# Subclasses that want to "opt in" aggregation of dense regions should
674+
# override this with `aggregate_dense_regions = Setting(True)`
675+
# (or `Setting(False)` if they want to have it disabled by default).
676+
aggregate_dense_regions = False
632677

633678
resolution = 256
634679

@@ -1749,11 +1794,13 @@ def help_event(self, event):
17491794
"""
17501795
if self.scatterplot_item is None:
17511796
return False
1752-
act_pos = self.scatterplot_item.mapFromScene(event.scenePos())
1753-
point_data = [p.data() for p in self.scatterplot_item.pointsAt(act_pos)]
1754-
text = self.master.get_tooltip(point_data)
1755-
if text:
1756-
QToolTip.showText(event.screenPos(), text, widget=self.plot_widget)
1757-
return True
1797+
pos = event.scenePos()
1798+
act_pos = self.scatterplot_item.mapFromScene(pos)
1799+
if len(points := self.scatterplot_item.aggregatedPointsAt(pos)) != 0:
1800+
text = self.master.get_aggregated_tooltip([p.data() for p in points])
1801+
elif len(points := self.scatterplot_item.pointsAt(act_pos)) != 0:
1802+
text = self.master.get_tooltip([p.data() for p in points])
17581803
else:
17591804
return False
1805+
QToolTip.showText(event.screenPos(), text, widget=self.plot_widget)
1806+
return True

Orange/widgets/visualize/tests/test_owscatterplot.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,8 @@ def test_tooltip(self):
664664
data = Table("heart_disease")
665665
self.send_signal(self.widget.Inputs.data, data)
666666
widget = self.widget
667+
widget.graph.aggregate_dense_regions = False
668+
667669
graph = widget.graph
668670
scatterplot_item = graph.scatterplot_item
669671

Orange/widgets/visualize/tests/test_owscatterplotbase.py

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
from unittest.mock import patch, Mock
55
import numpy as np
66

7-
from AnyQt.QtCore import QRectF, Qt
7+
from AnyQt.QtCore import QRectF, QPointF, Qt
88
from AnyQt.QtGui import QColor, QTransform
99
from AnyQt.QtTest import QSignalSpy
10-
1110
from pyqtgraph import mkPen, mkBrush
1211

1312
from orangewidget.tests.base import GuiTest
@@ -1364,6 +1363,42 @@ def test_allow_aggregation(self):
13641363
graph.jitter_size = 0
13651364
self.assertTrue(graph.allow_aggregation())
13661365

1366+
@patch("AnyQt.QtWidgets.QToolTip.showText")
1367+
def test_help_event(self, _):
1368+
master = self.master
1369+
graph = self.graph
1370+
event = Mock()
1371+
1372+
graph.scatterplot_item = None
1373+
self.assertFalse(graph.help_event(event))
1374+
1375+
scp = graph.scatterplot_item = Mock()
1376+
scp.mapFromScene = Mock()
1377+
scp.aggregatedPointsAt = Mock(return_value=[])
1378+
master.get_aggregated_tooltip = Mock()
1379+
scp.pointsAt = Mock(return_value=[])
1380+
master.get_tooltip = Mock()
1381+
1382+
self.assertFalse(graph.help_event(event))
1383+
1384+
mv = [Mock(), Mock()]
1385+
scp.pointsAt = Mock(return_value=mv)
1386+
self.assertTrue(graph.help_event(event))
1387+
master.get_aggregated_tooltip.assert_not_called()
1388+
master.get_tooltip.assert_called_with([mv[0].data.return_value,
1389+
mv[1].data.return_value])
1390+
master.get_aggregated_tooltip.reset_mock()
1391+
scp.pointsAt.reset_mock()
1392+
master.get_tooltip.reset_mock()
1393+
1394+
scp.aggregatedPointsAt = Mock(return_value=mv)
1395+
self.assertTrue(graph.help_event(event))
1396+
master.get_tooltip.assert_not_called()
1397+
scp.pointsAt.assert_not_called()
1398+
master.get_aggregated_tooltip.assert_called_with([mv[0].data.return_value,
1399+
mv[1].data.return_value])
1400+
1401+
13671402
def test_show_grid(self):
13681403
graph = self.graph
13691404
show_grid = self.graph.plot_widget.showGrid = Mock()
@@ -1732,43 +1767,86 @@ def test_get_aggregate_points(self):
17321767
painter = Mock()
17331768
painter.transform = lambda: QTransform(1, 0, 0, 0, -1, 0, 0, 0, 1)
17341769

1735-
self.assertEqual(scp._get_aggregated_points(painter), (None, None))
1770+
self.assertIsNone(scp._get_aggregated_points(painter))
17361771
np.testing.assert_equal(scp.data["visible"], np.ones(n))
17371772

17381773
scp.setAggregation(True)
1739-
pts, colors = scp._get_aggregated_points(painter)
1774+
scp._nonaggregated = True
1775+
colors = scp._get_aggregated_points(painter)
1776+
pts = scp._agg_coords
17401777
np.testing.assert_almost_equal(pts, [[ 1.16666667, -3.06666667]])
17411778
self.assertEqual(colors, [{(0, 0, 0, 255): 2, (10, 10, 10, 255): 1}])
1742-
np.testing.assert_equal(scp.data["visible"], [0, 0, 0, 1, 1, 1, 1])
1779+
np.testing.assert_equal(scp._nonaggregated, [0, 0, 0, 1, 1, 1, 1])
17431780

17441781
scp._aggregation_threshold = 2
1745-
pts, colors = scp._get_aggregated_points(painter)
1782+
scp._nonaggregated = True
1783+
colors = scp._get_aggregated_points(painter)
1784+
pts = scp._agg_coords
17461785
np.testing.assert_almost_equal(pts, [[1.16666667, -3.06666667],
17471786
[24.25, -100]])
17481787
self.assertEqual(colors, [
17491788
{(0, 0, 0, 255): 2, (10, 10, 10, 255): 1},
17501789
{(20, 20, 20, 255): 1, (40, 40, 40, 255): 1}
17511790
])
1752-
np.testing.assert_equal(scp.data["visible"], [0, 0, 0, 0, 1, 0, 1])
1791+
np.testing.assert_equal(scp._nonaggregated, [0, 0, 0, 0, 1, 0, 1])
17531792
scp.data["visible"] = np.ones(n)
17541793

17551794
scp._aggregation_threshold = 4
1756-
self.assertEqual(scp._get_aggregated_points(painter), (None, None))
1757-
np.testing.assert_equal(scp.data["visible"], np.ones(n))
1795+
scp._nonaggregated = True
1796+
self.assertIsNone(scp._get_aggregated_points(painter))
1797+
np.testing.assert_equal(scp._nonaggregated, np.ones(n))
17581798

17591799
def test_paint_aggregated_points(self):
17601800
scp = ScatterPlotItem()
17611801
painter = Mock()
17621802

1763-
pts = [[1, 2], [3, 4]]
1803+
scp._agg_coordspts = [[1, 2], [3, 4]]
17641804
colors = [
17651805
{(0, 0, 0, 255): 2, (10, 10, 10, 255): 1},
17661806
{(20, 20, 20, 255): 3}
17671807
]
17681808

17691809
# Well ... don't crash, OK?
1770-
scp._paint_aggregated_points(painter, pts, colors)
1771-
scp._paint_aggregated_points(painter, None, None)
1810+
scp._paint_aggregated_points(painter, colors)
1811+
scp._paint_aggregated_points(painter, None)
1812+
1813+
def test_pointsAt(self):
1814+
x, y = np.array([[1, 3], [1, 3], [1.5, 3.2],
1815+
[24, 100],
1816+
[200, 1],
1817+
[24.5, 100], [200, 20]]).T
1818+
n = len(x)
1819+
scp = ScatterPlotItem(x=x, y=y)
1820+
scp.setBrush([mkBrush(QColor(0, 0, 0))] +
1821+
[mkBrush(QColor(10 * i, 10 * i, 10 * i)) for i in range(n - 1)])
1822+
scp.setPointData(np.arange(n))
1823+
scp._agg_size_default = 10
1824+
scp._maskAt = Mock(return_value=np.arange(n - 1))
1825+
painter = Mock()
1826+
painter.transform = lambda: QTransform(1, 0, 0, 0, -1, 0, 0, 0, 1)
1827+
scp.setAggregation(True)
1828+
scp._aggregation_threshold = 2
1829+
1830+
scp._nonaggregated = True
1831+
agg_colors = scp._get_aggregated_points(painter)
1832+
scp._paint_aggregated_points(painter, agg_colors)
1833+
1834+
self.assertEqual(
1835+
{p.data() for p in scp.aggregatedPointsAt(QPointF(1.2, 1.3))}, {0, 1, 2})
1836+
self.assertEqual(
1837+
len(scp.aggregatedPointsAt(QPointF(200, 1))), 0)
1838+
1839+
def test_mask(*_, **__):
1840+
np.testing.assert_equal(
1841+
scp.data["visible"],
1842+
[False, False, False, False, True, False, True])
1843+
1844+
with patch(
1845+
"pyqtgraph.graphicsItems.ScatterPlotItem.ScatterPlotItem.pointsAt",
1846+
new=test_mask):
1847+
scp.pointsAt(QPointF(1.2, 1.3))
1848+
np.testing.assert_equal(scp.data["visible"], True)
1849+
17721850

17731851

17741852
if __name__ == "__main__":

Orange/widgets/visualize/utils/widget.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,20 @@ def get_tooltip(self, point_ids):
341341
text = f"{len(point_ids)} instances<hr/>{text}<hr/>..."
342342
return text
343343

344+
def get_aggregated_tooltip(self, point_ids):
345+
"""
346+
Return tooltip for aggregate points (e.g. piecharts).
347+
348+
Default implementation falls back to get_tooltip
349+
350+
Args:
351+
point_ids (list): indices into 'data'
352+
353+
Returns:
354+
tooltip (str)
355+
"""
356+
return self.get_tooltip(point_ids)
357+
344358
def keyPressEvent(self, event):
345359
"""Update the tip about using the modifier keys when selecting"""
346360
super().keyPressEvent(event)

i18n/si/msgs.jaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13645,7 +13645,7 @@ widgets/utils/plot/owplotgui.py:
1364513645
Aggregate points in dense regions: Združi točke v gostih regijah
1364613646
'Dense regions with many points are aggregated into ': "Goste regije z veliko točkami so združene v "
1364713647
circles or pie charts,\n: kroge ali tortne diagrame,\n
13648-
unless data is jittered or labels, selection or subset is shown: razen če so točke tresene ali pa so prikazane oznake, izbori ali podmnožice.
13648+
unless data is jittered or labels, selection or subset is shown.: razen če so točke tresene ali pa so prikazane oznake, izbori ali podmnožice.
1364913649
def `regression_line_check_box`:
1365013650
show_reg_line: false
1365113651
Show regression line: Pokaži regresijsko premico

0 commit comments

Comments
 (0)