Skip to content

Commit cdd53c8

Browse files
authored
Merge pull request #4504 from janezd/scatterplot-z-order
[ENH] Impose a sensible z-order to points in projections
2 parents 711a79b + 995220d commit cdd53c8

File tree

2 files changed

+319
-17
lines changed

2 files changed

+319
-17
lines changed

Orange/widgets/visualize/owscatterplotgraph.py

Lines changed: 106 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ def restoreAnchor(self, anchors):
128128
anchor, parentanchor = anchors
129129
self.anchor(*bound_anchor_pos(anchor, parentanchor))
130130

131-
def paint(self, painter, option, widget=None):
131+
# pylint: disable=arguments-differ
132+
def paint(self, painter, _option, _widget=None):
132133
painter.setPen(self.__pen)
133134
painter.setBrush(self.__brush)
134135
rect = self.contentsRect()
@@ -244,22 +245,60 @@ def __init__(self, scatter_widget, parent=None, _="None", view_box=InteractiveVi
244245

245246

246247
class ScatterPlotItem(pg.ScatterPlotItem):
247-
"""PyQtGraph's ScatterPlotItem calls updateSpots at any change of sizes/colors/symbols,
248-
which then rebuilds the stored pixmaps for each symbol. Because Orange calls
249-
set* function in succession, we postpone updateSpots() to paint()."""
248+
"""
249+
Modifies the behaviour of ScatterPlotItem as follows:
250+
251+
- Add z-index. ScatterPlotItem paints points in order of appearance in
252+
self.data. Plotting by z-index is achieved by sorting before calling
253+
super().paint() and re-sorting afterwards. Re-sorting (instead of
254+
storing the original data) is needed because the inherited paint
255+
may modify the data.
256+
257+
- Prevent multiple calls to updateSpots. ScatterPlotItem calls updateSpots
258+
at any change of sizes/colors/symbols, which then rebuilds the stored
259+
pixmaps for each symbol. Orange calls set* functions in succession,
260+
so we postpone updateSpots() to paint()."""
261+
262+
def __init__(self, *args, **kwargs):
263+
super().__init__(*args, **kwargs)
264+
self._update_spots_in_paint = False
265+
self._z_mapping = None
266+
self._inv_mapping = None
267+
268+
def setZ(self, z):
269+
"""
270+
Set z values for all points.
271+
272+
Points with higher values are plotted on top of those with lower.
250273
251-
_update_spots_in_paint = False
274+
Args:
275+
z (np.ndarray or None): a vector of z values
276+
"""
277+
if z is None:
278+
self._z_mapping = self._inv_mapping = None
279+
else:
280+
assert len(z) == len(self.data)
281+
self._z_mapping = np.argsort(z)
282+
self._inv_mapping = np.argsort(self._z_mapping)
252283

253284
def updateSpots(self, dataSet=None): # pylint: disable=unused-argument
254285
self._update_spots_in_paint = True
255286
self.update()
256287

288+
# pylint: disable=arguments-differ
257289
def paint(self, painter, option, widget=None):
258-
if self._update_spots_in_paint:
259-
self._update_spots_in_paint = False
260-
super().updateSpots()
261-
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
262-
super().paint(painter, option, widget)
290+
try:
291+
if self._z_mapping is not None:
292+
assert len(self._z_mapping) == len(self.data)
293+
self.data = self.data[self._z_mapping]
294+
if self._update_spots_in_paint:
295+
self._update_spots_in_paint = False
296+
super().updateSpots()
297+
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
298+
super().paint(painter, option, widget)
299+
finally:
300+
if self._inv_mapping is not None:
301+
self.data = self.data[self._inv_mapping]
263302

264303

265304
def _define_symbols():
@@ -581,7 +620,7 @@ def _get_jittering_tooltip(self):
581620
def update_jittering(self):
582621
self.update_tooltip()
583622
x, y = self.get_coordinates()
584-
if x is None or not len(x) or self.scatterplot_item is None:
623+
if x is None or len(x) == 0 or self.scatterplot_item is None:
585624
return
586625
self._update_plot_coordinates(self.scatterplot_item, x, y)
587626
self._update_plot_coordinates(self.scatterplot_item_sel, x, y)
@@ -766,7 +805,9 @@ def _jitter_data(self, x, y, span_x=None, span_y=None):
766805

767806
def _update_plot_coordinates(self, plot, x, y):
768807
"""
769-
Change the coordinates of points while keeping other properites
808+
Change the coordinates of points while keeping other properites.
809+
810+
Asserts that the number of points stays the same.
770811
771812
Note. Pyqtgraph does not offer a method for this: setting coordinates
772813
invalidates other data. We therefore retrieve the data to set it
@@ -776,6 +817,7 @@ def _update_plot_coordinates(self, plot, x, y):
776817
update for every property would essentially reset the graph, which
777818
can be time consuming.
778819
"""
820+
assert self.n_shown == len(x) == len(y)
779821
data = dict(x=x, y=y)
780822
for prop in ('pen', 'brush', 'size', 'symbol', 'data',
781823
'sourceRect', 'targetRect'):
@@ -793,7 +835,7 @@ def update_coordinates(self):
793835
the complete update by calling `reset_graph` instead of this method.
794836
"""
795837
x, y = self.get_coordinates()
796-
if x is None or not len(x):
838+
if x is None or len(x) == 0:
797839
return
798840
if self.scatterplot_item is None:
799841
if self.sample_indices is None:
@@ -1012,9 +1054,9 @@ def _get_continuous_colors(self, c_data, subset):
10121054
# Reuse pens and brushes with the same colors because PyQtGraph then
10131055
# builds smaller pixmap atlas, which makes the drawing faster
10141056

1015-
def reuse(cache, fn, *args):
1057+
def reuse(cache, fun, *args):
10161058
if args not in cache:
1017-
cache[args] = fn(args)
1059+
cache[args] = fun(args)
10181060
return cache[args]
10191061

10201062
def create_pen(col):
@@ -1072,7 +1114,7 @@ def _get_discrete_colors(self, c_data, subset):
10721114

10731115
def update_colors(self):
10741116
"""
1075-
Trigger an update of point sizes
1117+
Trigger an update of point colors
10761118
10771119
The method calls `self.get_colors`, which in turn calls the widget's
10781120
`get_color_data` to get the indices in the pallette. `get_colors`
@@ -1084,6 +1126,7 @@ def update_colors(self):
10841126
pen_data, brush_data = self.get_colors()
10851127
self.scatterplot_item.setPen(pen_data, update=False, mask=None)
10861128
self.scatterplot_item.setBrush(brush_data, mask=None)
1129+
self.update_z_values()
10871130
self.update_legends()
10881131
self.update_density()
10891132

@@ -1128,6 +1171,7 @@ def update_selection_colors(self):
11281171
pen, brush = self.get_colors_sel()
11291172
self.scatterplot_item_sel.setPen(pen, update=False, mask=None)
11301173
self.scatterplot_item_sel.setBrush(brush, mask=None)
1174+
self.update_z_values()
11311175

11321176
def get_colors_sel(self):
11331177
"""
@@ -1292,6 +1336,52 @@ def update_shapes(self):
12921336
self.scatterplot_item.setSymbol(shape_data)
12931337
self.update_legends()
12941338

1339+
def update_z_values(self):
1340+
"""
1341+
Set z-values for point in the plot
1342+
1343+
The order is as follows:
1344+
- selected points that are also in the subset on top,
1345+
- followed by selected points,
1346+
- followed by points from the subset,
1347+
- followed by the rest.
1348+
Within each of these four groups, points are ordered by their colors.
1349+
1350+
Points with less frequent colors are above those with more frequent.
1351+
The points for which the value for the color is missing are at the
1352+
bottom of their respective group.
1353+
"""
1354+
if not self.scatterplot_item:
1355+
return
1356+
1357+
subset = self.master.get_subset_mask()
1358+
c_data = self.master.get_color_data()
1359+
if subset is None and self.selection is None and c_data is None:
1360+
self.scatterplot_item.setZ(None)
1361+
return
1362+
1363+
z = np.zeros(self.n_shown)
1364+
1365+
if subset is not None:
1366+
subset = self._filter_visible(subset)
1367+
z[subset] += 1000
1368+
1369+
if self.selection is not None:
1370+
z[self._filter_visible(self.selection) != 0] += 2000
1371+
1372+
if c_data is not None:
1373+
c_nan = np.isnan(c_data)
1374+
vis_data = self._filter_visible(c_data)
1375+
vis_nan = np.isnan(vis_data)
1376+
z[vis_nan] -= 999
1377+
if not self.master.is_continuous_color():
1378+
dist = np.bincount(c_data[~c_nan].astype(int))
1379+
vis_knowns = vis_data[~vis_nan].astype(int)
1380+
argdist = np.argsort(dist)
1381+
z[~vis_nan] -= argdist[vis_knowns]
1382+
1383+
self.scatterplot_item.setZ(z)
1384+
12951385
def update_grid_visibility(self):
12961386
"""Show or hide the grid"""
12971387
self.plot_widget.showGrid(x=self.show_grid, y=self.show_grid)

0 commit comments

Comments
 (0)