Skip to content

Commit 6d83c9c

Browse files
committed
owchoropleth: speed up by removing unnecessary re-draws
1 parent 33201bc commit 6d83c9c

File tree

2 files changed

+96
-80
lines changed

2 files changed

+96
-80
lines changed

orangecontrib/geo/widgets/owchoropleth.py

Lines changed: 92 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from xml.sax.saxutils import escape
44
from typing import List, NamedTuple, Optional, Union, Callable
55
from math import floor, log10
6+
from functools import reduce
67

78
from AnyQt.QtCore import Qt, QObject, QSize, QRectF, pyqtSignal as Signal, \
89
QPointF
@@ -58,8 +59,7 @@
5859
_ChoroplethRegion = NamedTuple(
5960
"_ChoroplethRegion", [
6061
("id", str),
61-
("qpoly", QPolygonF),
62-
("agg_value", float),
62+
("qpolys", List[QPolygonF]),
6363
("info", dict),
6464
]
6565
)
@@ -116,26 +116,33 @@ def paint(self, p, *args):
116116

117117
class ChoroplethItem(pg.GraphicsObject):
118118
"""
119-
GraphicsObject that is a polygon that represents part of region.
120-
Regions can consist of multiple disjoint polygons so they are represented
121-
with multiple ChoroplethItem.
119+
GraphicsObject that represents regions.
120+
Regions can consist of multiple disjoint polygons.
122121
"""
123122

124123
itemClicked = Signal(str) # send region id
125124

126125
def __init__(self, region: _ChoroplethRegion, pen: QPen, brush: QBrush):
127126
pg.GraphicsObject.__init__(self)
128127
self.region = region
128+
self.agg_value = None
129129
self.pen = pen
130130
self.brush = brush
131-
self.tooltip_text = self._tooltip(self.region)
131+
132+
self._region_info = self._get_region_info(self.region)
133+
self._bounding_rect = reduce(
134+
lambda br1, br2: br1.united(br2),
135+
(qpoly.boundingRect() for qpoly in self.region.qpolys)
136+
)
132137

133138
@staticmethod
134-
def _tooltip(region: _ChoroplethRegion):
135-
agg_text = f"<b>Agg. value = {region.agg_value}</b><hr/>"
139+
def _get_region_info(region: _ChoroplethRegion):
136140
region_text = "<br/>".join(escape('{} = {}'.format(k, v))
137141
for k, v in region.info.items())
138-
return agg_text + "<b>Region info:</b><br/>" + region_text
142+
return "<b>Region info:</b><br/>" + region_text
143+
144+
def tooltip(self):
145+
return f"<b>Agg. value = {self.agg_value}</b><hr/>{self._region_info}"
139146

140147
def setPen(self, pen):
141148
self.pen = pen
@@ -148,16 +155,19 @@ def setBrush(self, brush):
148155
def paint(self, p: QPainter, *args):
149156
p.setBrush(self.brush)
150157
p.setPen(self.pen)
151-
p.drawPolygon(self.region.qpoly)
158+
for qpoly in self.region.qpolys:
159+
p.drawPolygon(qpoly)
152160

153161
def boundingRect(self) -> QRectF:
154-
return self.region.qpoly.boundingRect()
162+
return self._bounding_rect
155163

156164
def contains(self, point: QPointF) -> bool:
157-
return self.region.qpoly.containsPoint(point, Qt.OddEvenFill)
165+
return any(qpoly.containsPoint(point, Qt.OddEvenFill)
166+
for qpoly in self.region.qpolys)
158167

159168
def intersects(self, poly: QPolygonF) -> bool:
160-
return not self.region.qpoly.intersected(poly).isEmpty()
169+
return any(not qpoly.intersected(poly).isEmpty()
170+
for qpoly in self.region.qpolys)
161171

162172
def mouseClickEvent(self, ev):
163173
if ev.button() == Qt.LeftButton and self.contains(ev.pos()):
@@ -263,7 +273,8 @@ def update_choropleth(self):
263273
"""Draw new polygons."""
264274
pen = self._make_pen(QColor(Qt.white), 1)
265275
brush = QBrush(Qt.NoBrush)
266-
for region in self.master.get_choropleth_regions():
276+
regions = self.master.get_choropleth_regions()
277+
for region in regions:
267278
choropleth_item = ChoroplethItem(region, pen=pen, brush=brush)
268279
choropleth_item.itemClicked.connect(self.select_by_id)
269280
self.plot_widget.addItem(choropleth_item)
@@ -273,15 +284,15 @@ def update_choropleth(self):
273284
self.n_ids = len(self.master.region_ids)
274285

275286
def update_colors(self):
276-
"""Update inner color of existing polygons."""
287+
"""Update agg_value and inner color of existing polygons."""
277288
if not self.choropleth_items:
278289
return
279290

291+
agg_data = self.master.get_agg_data()
280292
brushes = self.get_colors()
281-
rid2brush = {rid: b
282-
for rid, b in zip(self.master.region_ids, brushes)}
283-
for ci in self.choropleth_items:
284-
ci.setBrush(rid2brush[ci.region.id])
293+
for ci, d, b in zip(self.choropleth_items, agg_data, brushes):
294+
ci.agg_value = d
295+
ci.setBrush(b)
285296
self.update_legends()
286297

287298
def get_colors(self):
@@ -352,9 +363,8 @@ def update_selection_colors(self):
352363
Update color of selected regions.
353364
"""
354365
pens = self.get_colors_sel()
355-
rid2pen = {rid: pen for rid, pen in zip(self.master.region_ids, pens)}
356-
for ci in self.choropleth_items:
357-
ci.setPen(rid2pen[ci.region.id])
366+
for ci, pen in zip(self.choropleth_items, pens):
367+
ci.setPen(pen)
358368

359369
def get_colors_sel(self):
360370
white_pen = self._make_pen(QColor(Qt.white), 1)
@@ -400,8 +410,7 @@ def select_by_id(self, region_id):
400410

401411
def select_by_rectangle(self, rect: QRectF):
402412
"""
403-
Find polygons that intersect with selected rectangle and select all
404-
corresponding regions.
413+
Find regions that intersect with selected rectangle.
405414
"""
406415
poly_rect = QPolygonF(rect)
407416
indices = set()
@@ -475,7 +484,7 @@ def help_event(self, event):
475484
ci = next((ci for ci in self.choropleth_items
476485
if ci.contains(act_pos)), None)
477486
if ci is not None:
478-
QToolTip.showText(event.screenPos(), ci.tooltip_text,
487+
QToolTip.showText(event.screenPos(), ci.tooltip(),
479488
widget=self.plot_widget)
480489
return True
481490
else:
@@ -634,8 +643,9 @@ def _add_controls(self):
634643
**options)
635644

636645
self.agg_func_combo = gui.comboBox(agg_box, self, 'agg_func',
637-
label='Agg.:', items=list(AGG_FUNCS),
638-
callback=self.setup_plot,
646+
label='Agg.:',
647+
items=[DEFAULT_AGG_FUNC],
648+
callback=self.graph.update_colors,
639649
**options)
640650

641651
a_slider = gui.hSlider(agg_box, self, 'admin_level', minValue=0,
@@ -647,13 +657,14 @@ def _add_controls(self):
647657
b_slider = gui.hSlider(visualization_box, self, "binning_index",
648658
label="Bin width:", minValue=0,
649659
maxValue=max(1, len(self.binnings) - 1),
650-
createLabel=False, callback=self.colors_changed)
660+
createLabel=False,
661+
callback=self.graph.update_colors)
651662
b_slider.setFixedWidth(176)
652663

653664
av_slider = gui.hSlider(visualization_box, self, "graph.alpha_value",
654665
minValue=0, maxValue=255, step=10,
655666
label="Opacity:", createLabel=False,
656-
callback=self.colors_changed)
667+
callback=self.graph.update_colors)
657668
av_slider.setFixedWidth(176)
658669

659670
gui.checkBox(visualization_box, self, "graph.show_legend",
@@ -693,8 +704,8 @@ def set_data(self, data):
693704
if not (data_existed and self.data is not None and
694705
array_equal(effective_data.X, self.effective_data.X)):
695706
self.clear(cache=True)
696-
self.graph.clear()
697707
self.input_changed.emit(data)
708+
self.setup_plot()
698709
self.update_agg()
699710
self.apply_selection()
700711
self.unconditional_commit()
@@ -741,7 +752,7 @@ def update_agg(self):
741752
else:
742753
self.agg_func = DEFAULT_AGG_FUNC
743754

744-
self.setup_plot()
755+
self.graph.update_colors()
745756

746757
def setup_plot(self):
747758
self.controls.binning_index.setEnabled(not self.is_mode())
@@ -773,7 +784,7 @@ def commit(self):
773784
def send_data(self):
774785
data, graph_sel = self.data, self.graph.get_selection()
775786
group_sel, selected_data, ann_data = None, None, None
776-
if data is not None and len(data):
787+
if data is not None and len(data) and self.region_ids is not None:
777788
# we get selection by region ids so we have to map it to points
778789
group_sel = np.zeros(len(data), dtype=int)
779790
for id, s in zip(self.region_ids, graph_sel):
@@ -782,14 +793,14 @@ def send_data(self):
782793
id_indices = np.where(self.data_ids == id)[0]
783794
group_sel[id_indices] = s
784795

785-
if np.sum(graph_sel) > 0:
786-
selected_data = create_groups_table(data, group_sel, False, "Group")
796+
if np.sum(graph_sel) > 0:
797+
selected_data = create_groups_table(data, group_sel, False, "Group")
787798

788-
if data is not None:
789-
if np.max(graph_sel) > 1:
790-
ann_data = create_groups_table(data, group_sel)
791-
else:
792-
ann_data = create_annotated_table(data, group_sel.astype(bool))
799+
if data is not None:
800+
if np.max(graph_sel) > 1:
801+
ann_data = create_groups_table(data, group_sel)
802+
else:
803+
ann_data = create_annotated_table(data, group_sel.astype(bool))
793804

794805
self.output_changed.emit(selected_data)
795806
self.Outputs.selected_data.send(selected_data)
@@ -822,15 +833,18 @@ def get_palette(self):
822833
return self.agg_attr.palette
823834

824835
def get_color_data(self):
825-
return self.get_agg_data()
836+
return self.get_reduced_agg_data()
826837

827838
def get_color_labels(self):
828839
if self.is_mode():
829-
return self.get_agg_data(return_labels=True)
840+
return self.get_reduced_agg_data(return_labels=True)
830841
elif self.is_time():
831842
return self.agg_attr.str_val
832843

833-
def get_agg_data(self, return_labels=False):
844+
def get_reduced_agg_data(self, return_labels=False):
845+
"""
846+
This returns agg data or its labels. It also merges infrequent data.
847+
"""
834848
needs_merging = self.is_mode() \
835849
and len(self.agg_attr.values) >= MAX_COLORS
836850
if return_labels and not needs_merging:
@@ -865,10 +879,6 @@ def is_time(self):
865879
self.agg_attr.is_time and \
866880
self.agg_func not in ('Count', 'Count defined')
867881

868-
def colors_changed(self):
869-
if self.choropleth_regions:
870-
self.graph.update_colors()
871-
872882
@memoize_method(3)
873883
def get_regions(self, lat_attr, lon_attr, admin):
874884
"""
@@ -894,63 +904,70 @@ def get_regions(self, lat_attr, lon_attr, admin):
894904
for _id, poly in zip(unique_ids, get_shape(unique_ids))}
895905
return ids, region_info, polygons
896906

897-
@memoize_method(6)
898907
def get_grouped(self, lat_attr, lon_attr, admin, attr, agg_func):
899908
"""
900909
Get aggregation value for points grouped by regions.
901910
Returns:
902-
dict of region ids matched to their additional info,
903-
dict of region ids matched to their polygon,
904911
Series of aggregated values
905912
"""
906913
if attr is not None:
907914
data = self.data.get_column_view(attr)[0]
908915
else:
909916
data = np.ones(len(self.data))
910917

911-
ids, region_info, polygons = self.get_regions(lat_attr, lon_attr, admin)
918+
ids, _, _ = self.get_regions(lat_attr, lon_attr, admin)
912919
result = pd.Series(data, dtype=float)\
913920
.groupby(ids)\
914921
.agg(AGG_FUNCS[agg_func].transform)
915922

916-
return region_info, polygons, result
923+
return result
924+
925+
def get_agg_data(self) -> np.ndarray:
926+
result = self.get_grouped(self.attr_lat, self.attr_lon,
927+
self.admin_level, self.agg_attr,
928+
self.agg_func)
929+
930+
self.agg_data = np.array(result.values)
931+
self.region_ids = np.array(result.index)
932+
933+
arg_region_sort = np.argsort(self.region_ids)
934+
self.region_ids = self.region_ids[arg_region_sort]
935+
self.agg_data = self.agg_data[arg_region_sort]
936+
937+
self.recompute_binnings()
938+
939+
return self.agg_data
917940

918941
def _repr_val(self, value):
919942
if self.agg_func in ('Count', 'Count defined'):
920943
return f"{value:d}"
921944
else:
922945
return self.agg_attr.repr_val(value)
923946

924-
def _create_choropleth_regions(self):
925-
"""Recalculate regions and group by."""
926-
region_info, polygons, result = self.get_grouped(self.attr_lat,
927-
self.attr_lon,
928-
self.admin_level,
929-
self.agg_attr,
930-
self.agg_func)
931-
self.agg_data = np.array(result.values)
932-
self.region_ids = np.array(result.index)
933-
self.recompute_binnings()
947+
def get_choropleth_regions(self) -> List[_ChoroplethRegion]:
948+
"""Recalculate regions"""
949+
if not self.is_valid():
950+
return []
951+
952+
_, region_info, polygons = self.get_regions(self.attr_lat,
953+
self.attr_lon,
954+
self.admin_level)
934955

935956
regions = []
936-
for id, res in result.iteritems():
937-
if isinstance(polygons[id], MultiPolygon):
957+
for _id in polygons:
958+
if isinstance(polygons[_id], MultiPolygon):
938959
# some regions consist of multiple polygons
939-
poly = list(polygons[id].geoms)
960+
polys = list(polygons[_id].geoms)
940961
else:
941-
poly = [polygons[id]]
962+
polys = [polygons[_id]]
942963

943-
for _poly in poly:
944-
qpoly = self.poly2qpoly(transform(self.deg2canvas, _poly))
945-
regions.append(
946-
_ChoroplethRegion(id=id, agg_value=self._repr_val(res),
947-
info=region_info[id],
948-
qpoly=qpoly))
949-
self.choropleth_regions = regions
964+
qpolys = [self.poly2qpoly(transform(self.deg2canvas, poly))
965+
for poly in polys]
966+
regions.append(_ChoroplethRegion(id=_id, info=region_info[_id],
967+
qpolys=qpolys))
950968

951-
def get_choropleth_regions(self) -> List[_ChoroplethRegion]:
952-
if not self.choropleth_regions and self.is_valid():
953-
self._create_choropleth_regions()
969+
self.choropleth_regions = sorted(regions, key=lambda cr: cr.id)
970+
self.get_agg_data()
954971
return self.choropleth_regions
955972

956973
@staticmethod
@@ -968,7 +985,6 @@ def clear(self, cache=False):
968985
self.choropleth_regions = []
969986
if cache:
970987
self.get_regions.cache_clear()
971-
self.get_grouped.cache_clear()
972988

973989
def send_report(self):
974990
if self.data is None:

orangecontrib/geo/widgets/tests/test_owchoropleth.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
from Orange.data import Table
77
from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin
8-
from Orange.widgets.visualize.owscatterplotgraph import PaletteItemSample, \
9-
SymbolItemSample
10-
from orangecontrib.geo.widgets.owchoropleth import OWChoropleth
8+
from Orange.widgets.visualize.owscatterplotgraph import SymbolItemSample
9+
from orangecontrib.geo.widgets.owchoropleth import OWChoropleth, \
10+
BinningPaletteItemSample
1111

1212

1313
class TestOWChoropleth(WidgetTest, WidgetOutputsTestMixin):
@@ -48,7 +48,7 @@ def test_discrete(self):
4848
self.send_signal(self.widget.Inputs.data, self.data)
4949
self.widget.admin_level = 1
5050
self.assertIsInstance(self.widget.graph.color_legend.items[0][0],
51-
PaletteItemSample)
51+
BinningPaletteItemSample)
5252
self.assertFalse(self.widget.is_mode())
5353

5454
self.widget.agg_func = "Mode"

0 commit comments

Comments
 (0)